Step 25.2: sim: summary builder
CI / check (push) Successful in 7m14s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-16 22:22:40 -04:00
parent fdbc46e165
commit c7044ad2d1
12 changed files with 511 additions and 3 deletions

View File

@ -1865,3 +1865,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The pop-up still opens on unseen deliveries and finished upgrades**, as before (`summaries`, `acknowledgeSummaries`): the logbook adds detail and a window, it doesn't change when the summary appears.
- **Goals are logged once** (`player.readyGoalsLogged`), so pruning old entries doesn't log them again. After the update, goals that were already ready are logged at the first catch-up.
- **Migration:** resolved dispatches still in the save (7 days of them) become flight entries with their top-ups, rests and discoveries. Level-ups and goals can't be rebuilt and are dropped; finished upgrades the player hadn't seen are logged by catch-up at their shop end.
## Step 25.2
- **The builder returns structure, not text.** Highlights and lines carry ids and amounts; the client words them from `content.awayWording` (and names places, goods, goals and pilots), so the sim stays free of wording and number formatting.
- **Problems come in as input**: they're found from the current state (fuel, boards, credits), not the logbook, so the engine finds them and passes them to the pure builder (Step 25.3).
- **"Events" for a short absence** are flights (deliveries and deadheads), discoveries, level-ups and finished upgrades. The pop-up also opens for a deadhead or a finished upgrade alone, and it should then list it. Refuels, rests and goals aren't events.
- **Each discovery, level-up, goal and scripted delivery is its own highlight**, in time order within its rank; the biggest job is one highlight (the highest payout, the earliest on a tie); each problem is one.
- **Pilot lines count flights**: a pilot who only deadheaded is active (0 jobs, a negative net). Ties in net go by name.

View File

@ -145,4 +145,6 @@ On the device the client keeps the save (IndexedDB, or files on native) and, in
Notifications (`packages/engine/src/notifications.ts`) also plan the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024).
`packages/sim/src/away.ts`: `buildAwaySummary({ from, to, entries, pilots, problems })` builds "While you were away" from logbook entries (CR-025): duration, totals, ≤3 highlights in priority order with an overflow count, ≤5 pilot lines by net with an overflow line and the idle count, and a direct list for short absences. `flightNet` is a flight's net. Its `LogbookEntry` type is the save's `LogEntry`.
`packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`) and at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them.

View File

@ -536,7 +536,7 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022
Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "CR-023 … Phase 23"). New decisions recorded as plan §0.5.140–0.5.142.
- [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10).
- [ ] Step 25.2 — Sim: summary builder
- [x] **Step 25.2 — Sim: summary builder.** `buildAwaySummary` (`packages/sim/src/away.ts`, pure) takes the absence (`from`, `to`), the logbook entries, every pilot with where they are now, and the problems found from the state, and returns the duration, totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in priority order (discovery, level-up, goal ready, scripted flavor, biggest job, problem) with the overflow count, at most 5 pilot lines by net with the rest summed and the idle count, and the direct list for 3 events or fewer. `flightNet` is payout − pilot share − fuel used − loan repayment. The sim's `LogbookEntry` and the save's `LogEntry` are checked to be the same type. Wording in content (`content.awayWording`, `{name}` placeholders). Tests: `packages/sim/test/away.test.ts` (50 pilots: 5 lines and an overflow line equal to the other 45; priority order and the cap with the right overflow; totals, with fuel bought not counted; the direct list for 2 events and not for 4; only the absence); `packages/engine/test/logbook.test.ts` (an entry's net equals the delivery summary's credits less its fuel); `packages/content/test/onboarding.test.ts` (the wording).
- [ ] Step 25.3 — Client: highlights pop-up
- [ ] Step 25.4 — Client: Logbook screen
- [ ] Step 25.5 — Regression

View File

@ -4,6 +4,7 @@ import { dealLoanAmount } from "./deal";
import { goods } from "./data/goods";
import { locations } from "./data/locations";
import {
awayWording,
onboardingGoals,
scriptedTutorialContracts,
tutorialCompanyName,
@ -35,6 +36,8 @@ export const content = {
/** The tutorial employer (CR-001) and the welcome cards (CR-022). */
tutorialCompanyName,
welcome,
/** "While you were away" wording (CR-025). */
awayWording,
upgrades,
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),

View File

@ -1,4 +1,10 @@
import type { OnboardingGoal, ScriptedTutorialContract, VeteranPilot, Welcome } from "../schemas";
import type {
AwayWording,
OnboardingGoal,
ScriptedTutorialContract,
VeteranPilot,
Welcome,
} from "../schemas";
/** The company the player flies for in the tutorial (CR-001). */
export const tutorialCompanyName = "Halvorsen Lunar Freight";
@ -25,6 +31,42 @@ export const welcome: Welcome = {
close: "Close",
};
/** "While you were away" (decision 0.5.140). */
export const awayWording: AwayWording = {
title: "While you were away · {duration}",
totals: {
net: "Net earned",
deliveries: "Deliveries",
discoveries: "Discoveries",
loanRepaid: "Loan repaid",
},
highlights: {
discovery: "New market: {place}",
levelUp: "{pilot} reached level {level} · {points}",
goalReady: "Goal ready to claim: {goal}",
bestJob: "Biggest job: {good} to {place}, {credits}, {pilot}",
noJobs: "{ship} has no job it can take at {place}",
lowFuel: "{ship} is too low on fuel to fly from {place}",
emergencyFuel: "{ship} is stuck at {place}: emergency fuel is available",
belowFloor: "Credits are below your pilots' spending floor ({floor})",
},
moreHighlights: "+{count} more in the Logbook",
pilotLine: "{pilot} · {jobs} · {credits} · {now}",
leveledUp: "Level up",
morePilots: "+{count} more pilots: {credits}",
idlePilots: "{count} pilots had nothing to do",
idlePilot: "1 pilot had nothing to do",
direct: {
delivery: "{ship} delivered {good} to {place}, {credits}",
deadhead: "{ship} flew empty to {place}, {fuel} fuel",
upgrade: "{ship} is out of the shop: {upgrade} level {level}",
discovery: "{pilot} opened a new market: {place}",
levelUp: "{pilot} reached level {level} · {points}",
},
viewLogbook: "View logbook",
done: "Done",
};
/** Special tutorial contracts by run (decision 0.5.33). */
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
{

View File

@ -455,3 +455,40 @@ export const welcomeSchema = z.object({
close: z.string().min(1),
});
export type Welcome = z.infer<typeof welcomeSchema>;
const line = z.string().min(1);
/**
* "While you were away" wording (decision 0.5.140). `{name}` marks a value
* the client fills in: places, goods, pilots, ships, credits and counts.
*/
export const awayWordingSchema = z.object({
title: line,
totals: z.object({ net: line, deliveries: line, discoveries: line, loanRepaid: line }),
highlights: z.object({
discovery: line,
levelUp: line,
goalReady: line,
bestJob: line,
noJobs: line,
lowFuel: line,
emergencyFuel: line,
belowFloor: line,
}),
moreHighlights: line,
pilotLine: line,
leveledUp: line,
morePilots: line,
idlePilots: line,
idlePilot: line,
direct: z.object({
delivery: line,
deadhead: line,
upgrade: line,
discovery: line,
levelUp: line,
}),
viewLogbook: line,
done: line,
});
export type AwayWording = z.infer<typeof awayWordingSchema>;

View File

@ -3,6 +3,7 @@ import {
content,
onboardingGoalSchema,
welcomeSchema,
awayWordingSchema,
onboardingGoals,
scriptedTutorialContractSchema,
scriptedTutorialContracts,
@ -87,3 +88,16 @@ describe("welcome cards (Step 22.1, decision 0.5.131)", () => {
).toBe(false);
});
});
describe("away summary wording (Step 25.2, decision 0.5.140)", () => {
it("validates, with the values each line fills in", () => {
expect(() => awayWordingSchema.parse(content.awayWording)).not.toThrow();
const { title, highlights, pilotLine, morePilots, moreHighlights } = content.awayWording;
expect(title).toContain("{duration}");
expect(highlights.levelUp).toMatch(/\{pilot\}.*\{level\}.*\{points\}/);
expect(highlights.bestJob).toMatch(/\{good\}.*\{place\}.*\{credits\}.*\{pilot\}/);
expect(pilotLine).toMatch(/\{pilot\}.*\{jobs\}.*\{credits\}.*\{now\}/);
expect(morePilots).toMatch(/\{count\}.*\{credits\}/);
expect(moreHighlights).toContain("{count}");
});
});

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { LogEntry, SaveDocument } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
import { flightNet, pilotLevel, skillPointsAvailable } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
addPilot,
@ -243,3 +243,20 @@ describe("logbook entries (Step 25.1, decision 0.5.141)", () => {
expect(player.awaySince).toBe(test.clock.now);
});
});
describe("net earnings (Step 25.2, decision 0.5.142)", () => {
it("matches the delivery summary: credits after pilot pay and loan, less the fuel used", async () => {
const test = newTestApp();
const o = await owner(test);
await edit(test.app, (s) => {
s.company.loanBalance = 50_000;
});
const { dispatch, save } = await deliver(test, o.avatarId, o.shipId);
const summary = (await test.app.call("getPlayerState", {})).summaries.find(
(s) => s.dispatchId === dispatch.id,
)!;
const [entry] = of(save, "flight");
expect(summary.loanRepayment).toBeGreaterThan(0);
expect(flightNet(entry!)).toBe(summary.credits - summary.fuelCost);
});
});

View File

@ -1,3 +1,4 @@
import type { LogbookEntry } from "@deadhead/sim";
import { z } from "zod";
import { idSchema, timeSchema } from "./entities";
@ -68,3 +69,8 @@ export const logEntrySchema = z.discriminatedUnion("kind", [
]);
export type LogEntry = z.infer<typeof logEntrySchema>;
export type LogEntryKind = LogEntry["kind"];
// The saved entry is the summary builder's own: a change on either side fails the build.
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _sameEntry: Same<LogEntry, LogbookEntry> = true;
void _sameEntry;

217
packages/sim/src/away.ts Normal file
View File

@ -0,0 +1,217 @@
import type { RealTime } from "./clock";
/**
* Logbook entries as the summary builder reads them (decision 0.5.141). The
* save's schema checks it stores exactly this shape.
*/
interface EntryBase {
id: string;
at: RealTime;
pilotId: string | null;
shipId: string | null;
}
export type LogbookEntry = EntryBase &
(
| {
kind: "flight";
dispatchId: string;
deadhead: boolean;
tutorial: boolean;
independent: boolean;
originId: string;
destinationId: string;
goodId: string | null;
massTonnes: number | null;
shipper: string | null;
payout: number;
pilotPay: number;
fuelCost: number;
loanRepayment: number;
xp: number;
scriptedId: string | null;
}
| {
kind: "refuel";
locationId: string;
boughtFu: number;
cost: number;
source: "top-up" | "bought" | "pilot" | "emergency";
}
| { kind: "rest"; until: RealTime }
| { kind: "upgrade"; upgradeId: string; level: number }
| { kind: "discovery"; locationId: string }
| { kind: "level-up"; level: number; skillPoints: number }
| { kind: "goal-ready"; goalId: string }
);
type Flight = Extract<LogbookEntry, { kind: "flight" }>;
/**
* A flight's net for the company (decision 0.5.142): payout minus the pilot's
* share, the fuel it used and the loan repayment. Fuel bought counts when used.
*/
export function flightNet(flight: Flight): number {
return flight.payout - flight.pilotPay - flight.fuelCost - flight.loanRepayment;
}
/** Something on the player's desk, found from the state, not the logbook. */
export type AwayProblem =
| { kind: "no-jobs"; shipId: string; locationId: string }
| { kind: "low-fuel"; shipId: string; locationId: string }
| { kind: "emergency-fuel"; shipId: string; locationId: string }
| { kind: "below-floor"; credits: number; floor: number };
/** The highlights, in priority order (decision 0.5.140). */
export type AwayHighlight =
| { kind: "discovery"; locationId: string; pilotId: string | null }
| { kind: "level-up"; pilotId: string | null; level: number; skillPoints: number }
| { kind: "goal-ready"; goalId: string }
| { kind: "scripted"; scriptedId: string }
| {
kind: "best-job";
goodId: string;
destinationId: string;
payout: number;
pilotId: string | null;
}
| { kind: "problem"; problem: AwayProblem };
export interface AwayPilotLine {
pilotId: string;
name: string;
/** Deliveries (not deadheads). */
deliveries: number;
net: number;
/** Where they are now, in words ("Resting at Ceres"). */
now: string;
leveledUp: boolean;
}
export interface AwaySummary {
durationMs: number;
totals: { net: number; deliveries: number; discoveries: number; loanRepaid: number };
highlights: AwayHighlight[];
/** Highlights past the first 3: "+N more in the Logbook". */
moreHighlights: number;
/** At most 5, by net profit. */
pilots: AwayPilotLine[];
/** The pilots past the first 5, summed; null when none. */
morePilots: { count: number; net: number } | null;
/** Pilots with no flights in the absence. */
idlePilots: number;
/**
* A short absence (3 events or fewer: flights, discoveries, level-ups,
* finished upgrades): those entries, oldest first, to list directly; else null.
*/
direct: LogbookEntry[] | null;
}
export interface AwayInput {
/** The absence: entries after `from`, up to and including `to`. */
from: RealTime;
to: RealTime;
entries: readonly LogbookEntry[];
/** Every pilot, with where they are now in words. */
pilots: readonly { id: string; name: string; now: string }[];
problems: readonly AwayProblem[];
}
export const MAX_HIGHLIGHTS = 3;
export const MAX_PILOT_LINES = 5;
export const SHORT_ABSENCE_EVENTS = 3;
const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]);
/**
* "While you were away" from the logbook (decision 0.5.140): totals, at most 3
* highlights in priority order, at most 5 pilot lines by net, and the short
* absence's direct list. Pure: the same entries always give the same summary.
*/
export function buildAwaySummary(input: AwayInput): AwaySummary {
const entries = input.entries
.filter((e) => e.at > input.from && e.at <= input.to)
.slice()
.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const of = <K extends LogbookEntry["kind"]>(kind: K) =>
entries.filter((e): e is Extract<LogbookEntry, { kind: K }> => e.kind === kind);
const flights = of("flight");
const deliveries = flights.filter((f) => !f.deadhead);
const discoveries = of("discovery");
const levelUps = of("level-up");
const totals = {
net: flights.reduce((sum, f) => sum + flightNet(f), 0),
deliveries: deliveries.length,
discoveries: discoveries.length,
loanRepaid: flights.reduce((sum, f) => sum + f.loanRepayment, 0),
};
// Biggest job: the highest payout, the earliest on a tie.
const best = deliveries.reduce<Flight | null>(
(top, f) => (top === null || f.payout > top.payout ? f : top),
null,
);
const highlights: AwayHighlight[] = [
...discoveries.map((d) => ({
kind: "discovery" as const,
locationId: d.locationId,
pilotId: d.pilotId,
})),
...levelUps.map((l) => ({
kind: "level-up" as const,
pilotId: l.pilotId,
level: l.level,
skillPoints: l.skillPoints,
})),
...of("goal-ready").map((g) => ({ kind: "goal-ready" as const, goalId: g.goalId })),
...flights.flatMap((f) =>
f.scriptedId ? [{ kind: "scripted" as const, scriptedId: f.scriptedId }] : [],
),
...(best && best.goodId
? [
{
kind: "best-job" as const,
goodId: best.goodId,
destinationId: best.destinationId,
payout: best.payout,
pilotId: best.pilotId,
},
]
: []),
...input.problems.map((problem) => ({ kind: "problem" as const, problem })),
];
const lines: AwayPilotLine[] = input.pilots
.map((pilot) => {
const own = flights.filter((f) => f.pilotId === pilot.id);
return {
pilotId: pilot.id,
name: pilot.name,
flights: own.length,
deliveries: own.filter((f) => !f.deadhead).length,
net: own.reduce((sum, f) => sum + flightNet(f), 0),
now: pilot.now,
leveledUp: levelUps.some((l) => l.pilotId === pilot.id),
};
})
.filter((line) => line.flights > 0)
.sort((a, b) => b.net - a.net || a.name.localeCompare(b.name))
.map(({ flights: _flights, ...line }) => line);
const rest = lines.slice(MAX_PILOT_LINES);
const events = entries.filter((e) => EVENT_KINDS.has(e.kind));
return {
durationMs: Math.max(0, input.to - input.from),
totals,
highlights: highlights.slice(0, MAX_HIGHLIGHTS),
moreHighlights: Math.max(0, highlights.length - MAX_HIGHLIGHTS),
pilots: lines.slice(0, MAX_PILOT_LINES),
morePilots:
rest.length > 0
? { count: rest.length, net: rest.reduce((sum, line) => sum + line.net, 0) }
: null,
idlePilots: input.pilots.length - lines.length,
direct: events.length <= SHORT_ABSENCE_EVENTS ? events : null,
};
}

View File

@ -134,3 +134,18 @@ export {
pastFirstLook,
} from "./independent";
export type { IndependentInput, JobOption, PilotAction, PilotOrders } from "./independent";
export {
buildAwaySummary,
flightNet,
MAX_HIGHLIGHTS,
MAX_PILOT_LINES,
SHORT_ABSENCE_EVENTS,
} from "./away";
export type {
AwayHighlight,
AwayInput,
AwayPilotLine,
AwayProblem,
AwaySummary,
LogbookEntry,
} from "./away";

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { buildAwaySummary, flightNet, type AwayProblem, type LogbookEntry } from "../src/index";
const FROM = 1_000_000;
const HOUR = 3_600_000;
let n = 0;
const flight = (pilotId: string, payout: number, over: Partial<LogbookEntry> = {}): LogbookEntry =>
({
id: `f${n++}`,
at: FROM + 60_000 + n,
pilotId,
shipId: `ship-${pilotId}`,
kind: "flight",
dispatchId: `d${n}`,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
payout,
pilotPay: Math.round(payout / 5),
fuelCost: 100,
loanRepayment: 0,
xp: 10,
scriptedId: null,
...over,
}) as LogbookEntry;
const other = (entry: Partial<LogbookEntry> & { kind: LogbookEntry["kind"] }): LogbookEntry =>
({ id: `e${n++}`, at: FROM + 30_000 + n, pilotId: "p0", shipId: null, ...entry }) as LogbookEntry;
const pilots = (count: number) =>
Array.from({ length: count }, (_, i) => ({ id: `p${i}`, name: `Pilot ${i}`, now: "At Earth" }));
const summarise = (
entries: LogbookEntry[],
extra: { pilots?: number; problems?: AwayProblem[] } = {},
) =>
buildAwaySummary({
from: FROM,
to: FROM + 10 * HOUR,
entries,
pilots: pilots(extra.pilots ?? 3),
problems: extra.problems ?? [],
});
describe("the away summary builder (Step 25.2, decision 0.5.140)", () => {
it("shows 5 pilot lines by net profit and sums the other 45 into one line", () => {
// Pilot i earns (i + 1) × 1,000 per job, two jobs each.
const entries = pilots(50).flatMap((p, i) => [
flight(p.id, (i + 1) * 1_000),
flight(p.id, (i + 1) * 1_000),
]);
const summary = summarise(entries, { pilots: 50 });
expect(summary.pilots.map((l) => l.pilotId)).toEqual(["p49", "p48", "p47", "p46", "p45"]);
expect(summary.pilots[0]).toMatchObject({ deliveries: 2, now: "At Earth", leveledUp: false });
const net = (id: string) =>
entries
.filter((e) => e.pilotId === id && e.kind === "flight")
.reduce((sum, e) => sum + flightNet(e as never), 0);
expect(summary.pilots[0]!.net).toBe(net("p49"));
const rest = pilots(45).map((p) => net(p.id));
expect(summary.morePilots).toEqual({ count: 45, net: rest.reduce((a, b) => a + b, 0) });
expect(summary.idlePilots).toBe(0);
expect(summary.direct).toBeNull();
});
it("puts highlights in priority order, at most 3, with the rest counted", () => {
const problem: AwayProblem = { kind: "low-fuel", shipId: "s1", locationId: "io-sulfur-works" };
const all = [
flight("p0", 8_420, { scriptedId: "unmarked-crates" }),
flight("p1", 2_000),
other({ kind: "goal-ready", goalId: "first-hire" }),
other({ kind: "level-up", level: 4, skillPoints: 1 }),
other({ kind: "discovery", locationId: "mars" }),
];
const summary = summarise(all, { problems: [problem] });
expect(summary.highlights.map((h) => h.kind)).toEqual(["discovery", "level-up", "goal-ready"]);
expect(summary.moreHighlights).toBe(3);
const fewer = summarise(all.slice(0, 2), { problems: [problem] });
expect(fewer.highlights).toEqual([
{ kind: "scripted", scriptedId: "unmarked-crates" },
{
kind: "best-job",
goodId: "consumer-goods",
destinationId: "moon",
payout: 8_420,
pilotId: "p0",
},
{ kind: "problem", problem },
]);
expect(fewer.moreHighlights).toBe(0);
});
it("adds up the totals from the entries", () => {
const entries = [
flight("p0", 3_000, { loanRepayment: 300 }),
flight("p1", 5_000),
flight("p1", 0, { deadhead: true, goodId: null, pilotPay: 0, fuelCost: 80 }),
other({ kind: "discovery", locationId: "mars" }),
other({ kind: "refuel", locationId: "earth", boughtFu: 10, cost: 90, source: "pilot" }),
];
const summary = summarise(entries);
const flights = entries.filter((e) => e.kind === "flight");
expect(summary.totals).toEqual({
net: flights.reduce((sum, e) => sum + flightNet(e as never), 0),
deliveries: 2,
discoveries: 1,
loanRepaid: 300,
});
// Fuel bought counts when it's used, not when it's bought (decision 0.5.142).
expect(summary.totals.net).toBe(3_000 - 600 - 100 - 300 + (5_000 - 1_000 - 100) - 80);
// Pilot 2 flew nothing.
expect(summary.idlePilots).toBe(1);
expect(summary.pilots.map((l) => [l.pilotId, l.deliveries])).toEqual([
["p1", 1],
["p0", 1],
]);
});
it("lists a short absence's events directly: 3 or fewer", () => {
const two = [flight("p0", 410), other({ kind: "level-up", level: 2, skillPoints: 1 })];
expect(summarise(two).direct!.map((e) => e.kind)).toEqual(["level-up", "flight"]);
const four = [flight("p0", 1), flight("p0", 2), flight("p1", 3), flight("p1", 4)];
expect(summarise(four).direct).toBeNull();
// Refuels and rests aren't events.
const quiet = [
flight("p0", 1),
other({ kind: "rest", until: FROM + HOUR }),
other({ kind: "refuel", locationId: "earth", boughtFu: 1, cost: 5, source: "bought" }),
];
expect(summarise(quiet).direct!.map((e) => e.kind)).toEqual(["flight"]);
});
it("covers only the absence, and says how long it was", () => {
const before = flight("p0", 9_999, { at: FROM });
const during = flight("p0", 1_000);
const summary = summarise([before, during]);
expect(summary.totals.deliveries).toBe(1);
expect(summary.durationMs).toBe(10 * HOUR);
});
});