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 = (save: SaveDocument, kind: K) => save.logbook.filter((e): e is Extract => 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([]); }); }); describe("seen entries (Step 28.1, decision 0.5.163)", () => { it("starts new entries unseen, marks them seen, and keeps that through a reload", async () => { const test = newTestApp(); const o = await owner(test); const { dispatch, save } = await deliver(test, o.avatarId, o.shipId); const flightId = `${dispatch.id}:flight`; expect(save.logbook.length).toBeGreaterThan(0); expect(save.logbook.every((e) => !e.seen)).toBe(true); const decidedAt = save.company.lastManualDecisionAt; test.clock.now += 60_000; expect( await test.app.call("markLogbookSeen", { entryIds: [flightId, "no-such-entry"] }), ).toEqual({ marked: 1 }); // Again: nothing new to mark. expect(await test.app.call("markLogbookSeen", { entryIds: [flightId] })).toEqual({ marked: 0 }); const after = await readSave(test.app); expect(after.logbook.find((e) => e.id === flightId)!.seen).toBe(true); expect(after.logbook.filter((e) => e.id !== flightId).every((e) => !e.seen)).toBe(true); // Seeing a toast isn't a decision: it doesn't wake the fleet (CR-024). expect(after.company.lastManualDecisionAt).toBe(decidedAt); // A reload (the save exported and opened again) keeps it. const { code } = await test.app.call("exportSave", {}); const reloaded = newTestApp(); reloaded.clock.now = test.clock.now; await reloaded.app.call("importSave", { code }); const book = await reloaded.app.call("getLogbook", {}); expect(book.entries.find((e) => e.id === flightId)!.seen).toBe(true); }); });