diff --git a/DECISIONS.md b/DECISIONS.md index dca5d5a..358f74d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 7e075f4..11f061a 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 3bf46c2..2f44dba 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 8d1dbb6..1a575cb 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -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 }), diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index b8644c5..59fe71a 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -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[] = [ { diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 56d46d8..ef732c2 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -455,3 +455,40 @@ export const welcomeSchema = z.object({ close: z.string().min(1), }); export type Welcome = z.infer; + +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; diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts index c3d09bb..a93287f 100644 --- a/packages/content/test/onboarding.test.ts +++ b/packages/content/test/onboarding.test.ts @@ -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}"); + }); +}); diff --git a/packages/engine/test/logbook.test.ts b/packages/engine/test/logbook.test.ts index 51e1e2d..033d576 100644 --- a/packages/engine/test/logbook.test.ts +++ b/packages/engine/test/logbook.test.ts @@ -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); + }); +}); diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index fe4d5a0..3ddb811 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -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; 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] extends [B] ? ([B] extends [A] ? true : false) : false; +const _sameEntry: Same = true; +void _sameEntry; diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts new file mode 100644 index 0000000..16149df --- /dev/null +++ b/packages/sim/src/away.ts @@ -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; + +/** + * 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(["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 = (kind: K) => + entries.filter((e): e is Extract => 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( + (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, + }; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 8e698a6..e145f22 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -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"; diff --git a/packages/sim/test/away.test.ts b/packages/sim/test/away.test.ts new file mode 100644 index 0000000..cc8ae08 --- /dev/null +++ b/packages/sim/test/away.test.ts @@ -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 => + ({ + 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 & { 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); + }); +});