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; /** Asks the player for permission (after the deal, not at first launch). */ requestPermission(): Promise; } /** 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(); 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 { const planned = save && this.enabled() ? plannedNotifications(save, now) : []; await this.scheduler.replaceAll(planned); } requestPermission(): Promise { return this.scheduler.requestPermission(); } }