deadhead-space-freight-tycoon/packages/engine/test/notifications.test.ts

269 lines
9.3 KiB
TypeScript

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<typeof setup>,
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([]);
});
});