Step 25.2: sim: summary builder
CI / check (push) Successful in 7m14s
Details
CI / check (push) Successful in 7m14s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fdbc46e165
commit
c7044ad2d1
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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).
|
- [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.3 — Client: highlights pop-up
|
||||||
- [ ] Step 25.4 — Client: Logbook screen
|
- [ ] Step 25.4 — Client: Logbook screen
|
||||||
- [ ] Step 25.5 — Regression
|
- [ ] Step 25.5 — Regression
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { dealLoanAmount } from "./deal";
|
||||||
import { goods } from "./data/goods";
|
import { goods } from "./data/goods";
|
||||||
import { locations } from "./data/locations";
|
import { locations } from "./data/locations";
|
||||||
import {
|
import {
|
||||||
|
awayWording,
|
||||||
onboardingGoals,
|
onboardingGoals,
|
||||||
scriptedTutorialContracts,
|
scriptedTutorialContracts,
|
||||||
tutorialCompanyName,
|
tutorialCompanyName,
|
||||||
|
|
@ -35,6 +36,8 @@ export const content = {
|
||||||
/** The tutorial employer (CR-001) and the welcome cards (CR-022). */
|
/** The tutorial employer (CR-001) and the welcome cards (CR-022). */
|
||||||
tutorialCompanyName,
|
tutorialCompanyName,
|
||||||
welcome,
|
welcome,
|
||||||
|
/** "While you were away" wording (CR-025). */
|
||||||
|
awayWording,
|
||||||
upgrades,
|
upgrades,
|
||||||
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
|
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
|
||||||
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),
|
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/** The company the player flies for in the tutorial (CR-001). */
|
||||||
export const tutorialCompanyName = "Halvorsen Lunar Freight";
|
export const tutorialCompanyName = "Halvorsen Lunar Freight";
|
||||||
|
|
@ -25,6 +31,42 @@ export const welcome: Welcome = {
|
||||||
close: "Close",
|
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). */
|
/** Special tutorial contracts by run (decision 0.5.33). */
|
||||||
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
|
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -455,3 +455,40 @@ export const welcomeSchema = z.object({
|
||||||
close: z.string().min(1),
|
close: z.string().min(1),
|
||||||
});
|
});
|
||||||
export type Welcome = z.infer<typeof welcomeSchema>;
|
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>;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
content,
|
content,
|
||||||
onboardingGoalSchema,
|
onboardingGoalSchema,
|
||||||
welcomeSchema,
|
welcomeSchema,
|
||||||
|
awayWordingSchema,
|
||||||
onboardingGoals,
|
onboardingGoals,
|
||||||
scriptedTutorialContractSchema,
|
scriptedTutorialContractSchema,
|
||||||
scriptedTutorialContracts,
|
scriptedTutorialContracts,
|
||||||
|
|
@ -87,3 +88,16 @@ describe("welcome cards (Step 22.1, decision 0.5.131)", () => {
|
||||||
).toBe(false);
|
).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}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { LogEntry, SaveDocument } from "@deadhead/schema";
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
addPilot,
|
addPilot,
|
||||||
|
|
@ -243,3 +243,20 @@ describe("logbook entries (Step 25.1, decision 0.5.141)", () => {
|
||||||
expect(player.awaySince).toBe(test.clock.now);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { LogbookEntry } from "@deadhead/sim";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { idSchema, timeSchema } from "./entities";
|
import { idSchema, timeSchema } from "./entities";
|
||||||
|
|
||||||
|
|
@ -68,3 +69,8 @@ export const logEntrySchema = z.discriminatedUnion("kind", [
|
||||||
]);
|
]);
|
||||||
export type LogEntry = z.infer<typeof logEntrySchema>;
|
export type LogEntry = z.infer<typeof logEntrySchema>;
|
||||||
export type LogEntryKind = LogEntry["kind"];
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -134,3 +134,18 @@ export {
|
||||||
pastFirstLook,
|
pastFirstLook,
|
||||||
} from "./independent";
|
} from "./independent";
|
||||||
export type { IndependentInput, JobOption, PilotAction, PilotOrders } 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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue