Files
Flight-Ops/lib/deriveEvents.ts
2026-09-19 14:52:36 +00:00

62 lines
2.7 KiB
TypeScript

import type { StateSnapshot } from './game/state';
import { PLANE_TYPES } from './game/catalog';
import { formatCash } from './format';
// ISS-008 (v0.17.0): derive human-readable game events by diffing two consecutive /api/state
// snapshots — there is no server event feed, so the client watches for transitions in the
// fields it already receives. Pure and unit-tested; the shell calls it once per new snapshot
// and turns each result into an info toast (plus a browser notification when hidden).
export interface GameEvent {
kind: 'info';
message: string;
}
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a
// "new package" event for every existing row would spam the stack on page load.
if (prev === null) return [];
const events: GameEvent[] = [];
// Planes: landed (flying → available, new locationName is the arrival airport), bought or
// sold (id set changed — ids are stable for a plane's whole life).
const prevPlanes = new Map(prev.planes.map((p) => [p.id, p]));
for (const plane of next.planes) {
const before = prevPlanes.get(plane.id);
if (!before) {
events.push({ kind: 'info', message: `Bought ${planeName(plane.model)}` });
continue;
}
if (before.status === 'flying' && plane.status === 'available') {
events.push({ kind: 'info', message: `${planeName(plane.model)} landed at ${plane.locationName}` });
}
}
for (const [id, before] of prevPlanes) {
if (!next.planes.some((p) => p.id === id)) {
events.push({ kind: 'info', message: `Sold ${planeName(before.model)}` });
}
}
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never
// deleted, so an id appearing is always a genuinely new one).
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id);
if (!before) {
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
events.push({ kind: 'info', message: `Package expired: ${pkg.originName} → ${pkg.destName}` });
}
}
return events;
}