import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { MS_PER_HOUR, repairCost, type EventOutcome, type TripEvent } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { addPilot, addShip, edit, insertDispatch, newTestApp, putBoard, readSave, setCredits, signUpOwner, testContract, type TestApp, } from "./support"; const MINUTE = 60_000; const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression; const none: EventOutcome = { delayMs: 0, hullLoss: 0, payLoss: 0, fee: 0, bonus: 0, xpBonusPct: 0, extraFuelFu: 0, }; /** A debris field 40% into a trip: go around (+3 h, the default) or push through (−15% hull). */ function debris(departureAt: number, arrivalAt: number): TripEvent { const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.4); return { eventId: "debris-field", kind: "radio", good: false, fireAt, windowEndsAt: Math.min(fireAt + 30 * MINUTE, arrivalAt), options: [ { id: "go-around", available: true, outcome: { ...none, delayMs: 3 * GAME_HOUR } }, { id: "push-through", available: true, outcome: { ...none, hullLoss: 15 } }, ], defaultOptionId: "go-around", }; } /** A grateful client's tip, automatic, 30% into a trip. */ function tip(departureAt: number, arrivalAt: number, bonus: number): TripEvent { const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.3); return { eventId: "grateful-client", kind: "automatic", good: true, fireAt, windowEndsAt: fireAt, options: [{ id: "tip", available: true, outcome: { ...none, bonus } }], defaultOptionId: "tip", }; } /** An owner whose Courier is on a long trip to Mars carrying `event`. */ async function flying( test: TestApp, make: (departureAt: number, arrivalAt: number) => TripEvent, options: { independent?: boolean } = {}, ) { const owner = await signUpOwner(test.app); const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id; const record = await insertDispatch(test.app, { shipId: owner.shipId, pilotId: avatar, departAt: test.clock.now, contract: testContract({ destinationId: "mars", payout: 8_000 }), }); const event = make(record.departureAt, record.arrivalAt); await edit(test.app, (save) => { const d = save.dispatches.find((x) => x.id === record.id)!; d.event = event; d.independent = options.independent ?? false; }); return { owner, record, event, avatar }; } const dispatchOf = (save: SaveDocument, id: string) => save.dispatches.find((d) => d.id === id)!; describe("events in play and catch-up (Step 26.5, decisions 0.5.143, 0.5.144)", () => { it("lets the player answer a radio call in its window, and not before or after", async () => { const test = newTestApp(); const { owner, record, event } = await flying(test, debris); const answer = (optionId: string) => test.app.call("answerRadioCall", { dispatchId: record.id, optionId }); await expect(answer("push-through")).rejects.toMatchObject({ code: "radio-call-not-found" }); test.clock.now = event.fireAt + MINUTE; const state = await test.app.call("getPlayerState", {}); expect(state.radioCalls).toEqual([ expect.objectContaining({ dispatchId: record.id, eventId: "debris-field", windowEndsAt: event.windowEndsAt, defaultOptionId: "go-around", }), ]); expect((await answer("push-through")).radioCalls).toEqual([]); const save = await readSave(test.app); const d = dispatchOf(save, record.id); expect(d.eventApplied).toMatchObject({ optionId: "push-through", answered: true, hullLoss: 15, }); expect(d.arrivalAt).toBe(record.arrivalAt); expect(save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct).toBe(85); await expect(answer("go-around")).rejects.toMatchObject({ code: "radio-call-closed" }); }); it("takes the default when the window closes unanswered: a 3-hour detour", async () => { const test = newTestApp(); const { record, event } = await flying(test, debris); test.clock.now = event.windowEndsAt + MINUTE; const state = await test.app.call("getPlayerState", {}); expect(state.radioCalls).toEqual([]); const save = await readSave(test.app); const d = dispatchOf(save, record.id); expect(d.eventApplied).toMatchObject({ optionId: "go-around", answered: false }); expect(d.arrivalAt).toBe(record.arrivalAt + 3 * GAME_HOUR); expect(save.logbook.find((e) => e.kind === "event")).toMatchObject({ at: event.windowEndsAt, eventId: "debris-field", optionId: "go-around", answered: false, delayMs: 3 * GAME_HOUR, }); await expect( test.app.call("answerRadioCall", { dispatchId: record.id, optionId: "push-through" }), ).rejects.toMatchObject({ code: "radio-call-closed" }); }); it("applies an automatic event when it fires, and pays its tip on delivery", async () => { const test = newTestApp(); const { record } = await flying(test, (from, to) => tip(from, to, 800)); test.clock.now = record.arrivalAt + MINUTE; const state = await test.app.call("getPlayerState", {}); const summary = state.summaries.find((s) => s.dispatchId === record.id)!; expect(summary.event).toMatchObject({ eventId: "grateful-client", bonus: 800 }); // 8,000 + 800 for the avatar, less the loan share if any. expect(summary.credits + summary.loanRepayment).toBe(8_800); }); it("gives an Independent pilot's unanswered call its default, and lets the player answer one", async () => { const test = newTestApp(); const { record, event } = await flying(test, debris, { independent: true }); test.clock.now = event.fireAt + MINUTE; expect((await test.app.call("getPlayerState", {})).radioCalls[0]).toMatchObject({ independent: true, }); test.clock.now = event.windowEndsAt; await test.app.call("getPlayerState", {}); expect(dispatchOf(await readSave(test.app), record.id).eventApplied).toMatchObject({ optionId: "go-around", answered: false, }); }); it("gives the same save caught up in one go or in pieces, defaulted calls included", async () => { const once = newTestApp(); const { record, event } = await flying(once, debris); await addShip(once.app, { id: "s2", modelId: "courier", name: "Wren" }); await addPilot(once.app, { id: "p2", name: "Ada", assignedShipId: "s2" }); const second = await insertDispatch(once.app, { shipId: "s2", pilotId: "p2", departAt: once.clock.now, contract: testContract({ destinationId: "mars", payout: 5_000 }), }); await edit(once.app, (save) => { dispatchOf(save, second.id).event = tip(second.departureAt, second.arrivalAt, 300); }); const { code } = await once.app.call("exportSave", {}); const pieces = newTestApp(); pieces.clock.now = once.clock.now; await pieces.app.call("importSave", { code }); const end = Math.max(record.arrivalAt, second.arrivalAt) + 8 * 60 * MINUTE; once.clock.now = end; await once.app.call("getPlayerState", {}); while (pieces.clock.now < end) { pieces.clock.now = Math.min(end, pieces.clock.now + 7 * MINUTE); await pieces.app.call("getPlayerState", {}); } const a = await readSave(once.app); const b = await readSave(pieces.app); expect(dispatchOf(a, record.id).eventApplied?.optionId).toBe("go-around"); expect(dispatchOf(a, record.id).status).toBe("resolved"); expect(a.logbook.filter((e) => e.kind === "event")).toHaveLength(2); expect(event.windowEndsAt).toBeLessThan(record.arrivalAt); expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); }); }); describe("events rolled at dispatch (Step 26.5, decision 0.5.145)", () => { it("makes the first event after the grace trips the derelict pod, and counts the pacing", async () => { const test = newTestApp(); await signUpOwner(test.app); await setCredits(test.app, 100_000); await addShip(test.app, { id: "c1", modelId: "clipper", name: "Heron" }); await addPilot(test.app, { id: "p1", name: "Rosa", assignedShipId: "c1" }); await edit(test.app, (save) => { save.player.dealAccepted = true; save.company.tripsSinceDeal = content.config.events.graceTripsAfterDeal; }); const job = testContract({ destinationId: "mars", expiresAt: test.clock.now + MS_PER_HOUR, }); await putBoard(test.app, [job], test.clock.now); const { dispatch } = await test.app.call("createDispatch", { kind: "contract", contractId: job.id, shipId: "c1", pilotId: "p1", }); const save = await readSave(test.app); const d = dispatchOf(save, dispatch.id); expect(d.event?.eventId).toBe("derelict-pod"); expect(d.event?.kind).toBe("radio"); expect(save.company).toMatchObject({ firstEventDone: true, tripsSinceDeal: 4 }); expect(save.ships.find((s) => s.id === "c1")!.quietTrips).toBe(0); }); it("has no events in the grace trips, while counting them", async () => { const test = newTestApp(); const owner = await signUpOwner(test.app); await edit(test.app, (save) => { save.player.dealAccepted = true; }); 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", }); const save = await readSave(test.app); expect(dispatchOf(save, dispatch.id).event).toBeNull(); expect(save.company.tripsSinceDeal).toBe(1); expect(save.ships.find((s) => s.id === owner.shipId)!.quietTrips).toBe(3); }); }); describe("repairs (Step 26.5, decision 0.5.148)", () => { it("repairs to 100% at an HQ: charged now, in the shop for the time, logged when done", async () => { const test = newTestApp(); const owner = await signUpOwner(test.app); await setCredits(test.app, 50_000); await edit(test.app, (save) => { save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct = 60; }); const cost = repairCost(content.shipModel("courier")!, 60, content.config); const started = test.clock.now; const { ship } = await test.app.call("repairShip", { shipId: owner.shipId }); expect(ship).toMatchObject({ inShopUntil: started + 20 * MINUTE, repairing: true }); expect((await readSave(test.app)).company.credits).toBe(50_000 - cost); test.clock.now = started + 20 * MINUTE; const state = await test.app.call("getPlayerState", {}); expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({ hullConditionPct: 100, repairing: false, }); expect((await readSave(test.app)).logbook.find((e) => e.kind === "repair")).toMatchObject({ shipId: owner.shipId, fromPct: 60, cost, }); await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({ code: "hull-intact", }); }); it("refuses a repair away from an HQ", async () => { const test = newTestApp(); const owner = await signUpOwner(test.app); await edit(test.app, (save) => { const ship = save.ships.find((s) => s.id === owner.shipId)!; ship.hullConditionPct = 60; ship.locationId = "moon"; }); await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({ code: "no-bay-at-location", }); }); });