150 lines
5.7 KiB
TypeScript
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();
|
|
}
|
|
}
|