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

150 lines
5.7 KiB
TypeScript

import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { MS_PER_MINUTE, type RealTime } from "@deadhead/sim";
import { sleepsAt } from "./game/state";
/**
* Local notifications (decision 0.5.91): the app schedules one for each
* arrival, each pilot's rest ending and each upgrade finishing; events in the
* same minute share one notification. One more marks the fleet going quiet
* (decision 0.5.139). The schedule is worked out from the save
* after every change and replaces whatever was scheduled before, so changed or
* cancelled events (a new company, an import) are dropped automatically.
*/
export interface ScheduledNotification {
/** Positive 32-bit integer (Capacitor's requirement). */
id: number;
at: RealTime;
title: string;
body: string;
}
/** The platform's notifications, injected (Capacitor on iOS and Android; nothing on the web). */
export interface NotificationScheduler {
/** Replaces every scheduled notification with these. */
replaceAll(notifications: ScheduledNotification[]): Promise<void>;
/** Asks the player for permission (after the deal, not at first launch). */
requestPermission(): Promise<void>;
}
/** The web build has no notifications. */
export const noNotifications: NotificationScheduler = {
replaceAll: async () => undefined,
requestPermission: async () => undefined,
};
interface GameEvent {
at: RealTime;
/** One line, e.g. "Dusty Mule landed at the Moon". */
text: string;
}
const locationName = (id: string) => content.location(id)?.name ?? id;
/** Every future event worth a notification, in time order. */
export function upcomingEvents(save: SaveDocument, now: RealTime): GameEvent[] {
const events: GameEvent[] = [];
for (const d of save.dispatches) {
if (d.status !== "in-flight" || d.arrivalAt <= now) continue;
// Pilots' own trips aren't announced (decision 0.5.130).
if (d.independent) continue;
const ship = d.tutorial
? "The company loaner"
: (save.ships.find((s) => s.id === d.shipId)?.name ?? "A ship");
const where = locationName(d.record.destinationId);
events.push({
at: d.arrivalAt,
text: d.record.contract ? `${ship} delivered to ${where}` : `${ship} arrived at ${where}`,
});
}
for (const p of save.pilots) {
if (p.restEndsAt !== null && p.restEndsAt > now) {
events.push({ at: p.restEndsAt, text: `${p.name} is rested and ready to fly` });
}
}
for (const s of save.ships) {
if (s.inShopUntil !== null && s.inShopUntil > now && s.shopUpgradeId) {
const upgrade = content.upgrade(s.shopUpgradeId)?.name ?? "Upgrade";
events.push({ at: s.inShopUntil, text: `${s.name} is out of the shop (${upgrade})` });
}
}
return events.sort((a, b) => a.at - b.at);
}
/** The notifications to schedule: one per minute that has events. */
export function plannedNotifications(save: SaveDocument, now: RealTime): ScheduledNotification[] {
const byMinute = new Map<number, GameEvent[]>();
for (const event of upcomingEvents(save, now)) {
const minute = Math.floor(event.at / MS_PER_MINUTE);
byMinute.set(minute, [...(byMinute.get(minute) ?? []), event]);
}
const planned = [...byMinute.values()].map((events, i) => ({
id: i + 1,
// The latest event of the minute, so everything in it has happened.
at: events.at(-1)!.at,
title: events.length === 1 ? "Deadhead" : `Deadhead: ${events.length} updates`,
body: events.map((e) => e.text).join("\n"),
}));
const sleep = sleepNotificationAt(save, now);
if (sleep !== null) {
planned.push({
id: planned.length + 1,
at: sleep,
title: "Deadhead",
body: "Your pilots are waiting for orders.",
});
}
for (const call of upcomingRadioCalls(save, now)) {
planned.push({ id: planned.length + 1, at: call.at, title: "Deadhead", body: call.text });
}
return planned;
}
/**
* Radio calls still to come on trips the player sent (decision 0.5.150): one
* notification each at its fire time, "Debris field ahead — Rosa needs a
* call." Independent pilots' trips are decided during catch-up, so theirs
* can't be scheduled ahead. Fleet sleep doesn't stop calls on trips in flight.
*/
export function upcomingRadioCalls(
save: SaveDocument,
now: RealTime,
): { at: RealTime; text: string }[] {
return save.dispatches.flatMap((d) => {
const event = d.event;
if (d.status !== "in-flight" || d.independent || event?.kind !== "radio") return [];
if (d.eventApplied || event.fireAt <= now) return [];
const title = content.tripEvent(event.eventId)?.title ?? "A radio call";
const pilot = save.pilots.find((p) => p.id === d.pilotId)?.name ?? "Your pilot";
return [{ at: event.fireAt, text: `${title} — ${pilot} needs a call.` }];
});
}
/**
* When the fleet goes quiet (decision 0.5.139), if that's ahead and there are
* Independent pilots for it to stop. A notification of its own: every manual
* decision moves it, so it never joins a minute's group.
*/
export function sleepNotificationAt(save: SaveDocument, now: RealTime): RealTime | null {
if (!save.pilots.some((p) => p.mode === "independent")) return null;
const at = sleepsAt(save, now);
return at > now ? at : null;
}
/** Keeps the platform's schedule matching the save; off (the Settings toggle) schedules nothing. */
export class NotificationSync {
constructor(
private readonly scheduler: NotificationScheduler,
private readonly enabled: () => boolean = () => true,
) {}
async sync(save: SaveDocument | null, now: RealTime): Promise<void> {
const planned = save && this.enabled() ? plannedNotifications(save, now) : [];
await this.scheduler.replaceAll(planned);
}
requestPermission(): Promise<void> {
return this.scheduler.requestPermission();
}
}