import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { fullSave } from "../../schema/test/save-fixture"; import { GameEngine, MemoryBackend, noNotifications, NotificationSync, plannedNotifications, SaveStorage, type NotificationScheduler, type ScheduledNotification, } from "../src/index"; import { addPilot, addShip, edit, makeOwner, putBoard, setCredits, signUp, TestClock, testContract, } from "./support"; /** The platform plugin, recording what is scheduled. */ class FakeScheduler implements NotificationScheduler { scheduled: ScheduledNotification[] = []; async replaceAll(list: ScheduledNotification[]) { this.scheduled = list; } async requestPermission() {} } function setup(enabled = () => true) { const clock = new TestClock(); const scheduler = new FakeScheduler(); const app = new GameEngine({ storage: new SaveStorage(new MemoryBackend()), clock: { now: () => clock.now }, notifications: new NotificationSync(scheduler, enabled), }); return { app, clock, scheduler }; } /** An owner who dispatches the Courier on a board contract; returns the flight. */ async function dispatchOne(app: GameEngine) { const guest = await signUp(app); const { shipId } = await makeOwner(app, guest.avatarId); await setCredits(app, 10_000); const contract = testContract(); await putBoard(app, [contract]); const { dispatch } = await app.call("createDispatch", { kind: "contract", contractId: contract.id, shipId, pilotId: guest.avatarId, }); return dispatch; } describe("local notifications (Step 14.9, decision 0.5.91)", () => { it("schedules one notification at a dispatch's arrival time", async () => { const { app, scheduler } = setup(); const dispatch = await dispatchOne(app); expect(scheduler.scheduled).toEqual([ { id: 1, at: dispatch.arrivalAt, title: "Deadhead", body: "Test Courier delivered to Moon", }, ]); }); it("combines events in the same minute into one notification", () => { const save = fullSave(); const landing = save.dispatches.find((d) => d.status === "in-flight")!; // A second ship lands 20 seconds after the first, in the same minute. const minuteStart = Math.floor(landing.arrivalAt / 60_000) * 60_000; landing.arrivalAt = minuteStart + 10_000; save.dispatches.push({ ...structuredClone(landing), id: "d2", shipId: "s2", arrivalAt: minuteStart + 30_000, record: { ...landing.record, id: "d2", shipId: "s2", destinationId: "mars" }, }); const now = save.lastTimeSeen; const planned = plannedNotifications(save, now); const combined = planned.find((n) => n.at === minuteStart + 30_000)!; expect(combined.title).toBe("Deadhead: 2 updates"); expect(combined.body.split("\n")).toEqual([ "Dusty Mule delivered to Moon", "Swift Heron delivered to Mars", ]); // The resting veteran and the shop job are separate minutes. expect(planned.map((n) => n.at)).toEqual([...planned.map((n) => n.at)].sort((a, b) => a - b)); expect(planned.length).toBeGreaterThanOrEqual(2); }); it("cancels everything when a new company starts", async () => { const { app, scheduler } = setup(); await dispatchOne(app); expect(scheduler.scheduled).toHaveLength(1); await app.call("newCompany", {}); expect(scheduler.scheduled).toEqual([]); }); it("schedules nothing with notifications turned off, and catches up when turned on", async () => { let on = false; const { app, scheduler } = setup(() => on); await dispatchOne(app); expect(scheduler.scheduled).toEqual([]); on = true; await app.resyncNotifications(); expect(scheduler.scheduled).toHaveLength(1); }); it("does nothing on the web (no-op scheduler), without errors", async () => { const app = new GameEngine({ storage: new SaveStorage(new MemoryBackend()), clock: { now: () => new TestClock().now }, notifications: new NotificationSync(noNotifications), }); await expect(dispatchOne(app)).resolves.toBeDefined(); await expect(app.requestNotificationPermission()).resolves.toBeUndefined(); }); }); describe("the sleep notification (Step 24.6, decision 0.5.139)", () => { const SLEEP = "Your pilots are waiting for orders."; const DAY = 24 * 60 * 60_000; const sleepNotes = (list: ScheduledNotification[]) => list.filter((n) => n.body === SLEEP); /** An owner with an Independent pilot and a Courier dispatched by hand. */ async function withIndependent(enabled = () => true) { const test = setup(enabled); const dispatch = await dispatchOne(test.app); 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" }); return { ...test, dispatch }; } it("schedules one at the last decision plus the threshold", async () => { const { scheduler, clock } = await withIndependent(); expect(sleepNotes(scheduler.scheduled)).toEqual([ expect.objectContaining({ at: clock.now + DAY, title: "Deadhead", body: SLEEP }), ]); }); it("moves it with the next manual decision, keeping just one", async () => { const { app, scheduler, clock } = await withIndependent(); clock.now += 3 * 60 * 60_000; await app.call("setRefuelPolicy", { shipId: "s2", policy: "auto" }); expect(sleepNotes(scheduler.scheduled)).toEqual([ expect.objectContaining({ at: clock.now + DAY }), ]); }); it("schedules none without an Independent pilot, with notifications off, or for a new company", async () => { const plain = setup(); await dispatchOne(plain.app); expect(sleepNotes(plain.scheduler.scheduled)).toEqual([]); let enabled = true; const off = await withIndependent(() => enabled); enabled = false; await off.app.resyncNotifications(); expect(off.scheduler.scheduled).toEqual([]); const fresh = await withIndependent(); await fresh.app.call("newCompany", {}); expect(sleepNotes(fresh.scheduler.scheduled)).toEqual([]); }); it("drops it once the fleet is asleep", () => { const save = fullSave(); save.pilots[0]!.mode = "independent"; save.company.lastManualDecisionAt = 0; expect(sleepNotes(plannedNotifications(save, DAY + 1))).toEqual([]); expect(sleepNotes(plannedNotifications(save, DAY - 1))).toEqual([ expect.objectContaining({ at: DAY }), ]); }); }); describe("radio-call notifications (Step 26.8, decision 0.5.150)", () => { const none = { delayMs: 0, hullLoss: 0, payLoss: 0, fee: 0, bonus: 0, xpBonusPct: 0, extraFuelFu: 0, }; const radio = (fireAt: number, windowEndsAt: number) => ({ eventId: "debris-field", kind: "radio" as const, good: false, fireAt, windowEndsAt, options: [ { id: "go-around", available: true, outcome: none }, { id: "push-through", available: true, outcome: none }, ], defaultOptionId: "go-around", }); const calls = (list: ScheduledNotification[]) => list.filter((n) => n.body.endsWith("needs a call.")); /** A dispatch by the player, given a radio call 40% into the trip (or none). */ async function withCall( test: ReturnType, options: { independent?: boolean; quiet?: boolean } = {}, ) { const dispatch = await dispatchOne(test.app); const fireAt = dispatch.departureAt + Math.round((dispatch.arrivalAt - dispatch.departureAt) * 0.4); await edit(test.app, (save) => { const d = save.dispatches.find((x) => x.id === dispatch.id)!; d.event = options.quiet ? null : radio(fireAt, dispatch.arrivalAt); d.independent = options.independent ?? false; }); await test.app.resyncNotifications(); return { dispatch, fireAt }; } it("schedules one at a radio call's fire time on a trip the player sent", async () => { const test = setup(); const { fireAt } = await withCall(test); const avatar = (await test.app.call("getPlayerState", {})).pilots.find((p) => p.isAvatar)!; expect(calls(test.scheduler.scheduled)).toEqual([ expect.objectContaining({ at: fireAt, title: "Deadhead", body: `Debris field ahead — ${avatar.name} needs a call.`, }), ]); }); it("schedules none for a quiet trip or an Independent pilot's", async () => { const quiet = setup(); await withCall(quiet, { quiet: true }); expect(calls(quiet.scheduler.scheduled)).toEqual([]); const own = setup(); await withCall(own, { independent: true }); expect(calls(own.scheduler.scheduled)).toEqual([]); }); it("cancels them when notifications are turned off, or the call has fired", async () => { let enabled = true; const test = setup(() => enabled); const { fireAt } = await withCall(test); expect(calls(test.scheduler.scheduled)).toHaveLength(1); enabled = false; await test.app.resyncNotifications(); expect(test.scheduler.scheduled).toEqual([]); enabled = true; test.clock.now = fireAt + 1; await test.app.resyncNotifications(); expect(calls(test.scheduler.scheduled)).toEqual([]); }); });