From 6c85f47f4ea39224eed0da0f11e12146fe7c2c1c Mon Sep 17 00:00:00 2001 From: fireshaper Date: Thu, 15 Oct 2026 21:36:14 -0400 Subject: [PATCH] Step 24.7: plan amendments and regression Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 ++++ .../change-requests/CR-011-settings-backup.md | 1 + .../change-requests/CR-014-local-first.md | 2 ++ .../CR-021-independent-pilots.md | 2 ++ memory-bank/implementation-plan.md | 1 + memory-bank/progress.md | 4 ++- packages/sim/balance/run.ts | 19 ++++++++++++++ packages/sim/balance/simulate.ts | 25 +++++++++++++++++-- packages/sim/test/balance.test.ts | 24 ++++++++++++++++++ 9 files changed, 80 insertions(+), 3 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index d185c1c..bf330aa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1845,3 +1845,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Scheduled from the save, like the others:** the notification scheduler already rebuilds the whole plan after every saved change and replaces what was scheduled. So "each manual decision reschedules it" and "cancelled on new company or import" need no code of their own: the sleep time moves with the decision, and a new or imported save plans its own. - **Only with an Independent pilot:** with none, nobody stops working, and "Your pilots are waiting for orders" would be untrue. - **Not grouped by minute:** it's a separate notification, so it never merges with an arrival in the same minute. + +## Step 24.7 + +- **The week-away check runs in the balance simulator**, beside the 8-hour scenario: the scenario's start is the player's last decision, and the fleet goes quiet the default 24 hours later. "A 24-hour absence plus the trips in flight at the sleep time" is measured as the 24-hour run's earnings plus the gross payouts of the week run's trips flying at that moment, which bounds anything that lands later. +- **CR-011's "Settings → Company" is the Settings screen's step (10H.2)**, where the Company section was laid out. diff --git a/memory-bank/change-requests/CR-011-settings-backup.md b/memory-bank/change-requests/CR-011-settings-backup.md index 298b554..83fb16f 100644 --- a/memory-bank/change-requests/CR-011-settings-backup.md +++ b/memory-bank/change-requests/CR-011-settings-backup.md @@ -58,6 +58,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5. ### Step 10H.2 — Client: settings screen and entry point - Add a gear icon to the header that opens the Settings screen with the sections in §2. The Developer section renders only in non-production builds. - **Test:** Component test: the gear opens Settings; all sections render; the Developer section is absent when the build mode is production. +- **Amended by CR-024:** Settings → Company gains "Fleet goes quiet after" (12 h, 24 h, 48 h or 3 days), the sleep threshold, stored with the company. ### Step 10H.3 — Client: display options applied everywhere - Route every clock display through one time-format helper that respects the clock preference. diff --git a/memory-bank/change-requests/CR-014-local-first.md b/memory-bank/change-requests/CR-014-local-first.md index fe74b82..74d81c0 100644 --- a/memory-bank/change-requests/CR-014-local-first.md +++ b/memory-bank/change-requests/CR-014-local-first.md @@ -100,6 +100,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5. - Move every server endpoint's logic into an engine command with the same input and output schemas. This includes all completed change requests (tutorial, deal, loan, goals, open markets, rest, goods and Priority, upgrades, veteran, settings actions, new ships). - Move the matching integration tests to run against the engine with in-memory storage and a fixed clock. - **Test:** The ported test suite passes. A test listing every command in the catalog confirms each has at least one engine test. +- **Amended by CR-024:** Each command in the catalog declares `manualDecision` (required): whether it is one of the player's decisions, from which the fleet's sleep time counts. A catalog test fails if one is missing. ### Step 10K.6 — Resolution loop - Resolve due dispatches, rest endings, and shop completions on load, when the app resumes (Capacitor `resume`, browser `visibilitychange`), and every 5 seconds while open. Uses the same idempotent resolution as Step 6.1. @@ -118,6 +119,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5. - Add a notification scheduler interface with a Capacitor Local Notifications implementation (native) and a no-op implementation (web). Schedule per decision 0.5.70 whenever a dispatch, rest, or upgrade is created; cancel and reschedule when state changes; cancel all on new company or import. - Ask for permission right after the deal is accepted. Add a **Notifications** on/off toggle to Settings (CR-008 left this out because push didn't exist). - **Test:** With a mocked plugin: dispatching schedules one notification at the arrival time; two arrivals in the same minute produce one combined notification; starting a new company cancels all scheduled notifications; with the toggle off, nothing is scheduled. On web, the no-op scheduler is used and nothing errors. +- **Amended by CR-024:** The scheduler also plans one notification at the fleet's sleep time ("Your pilots are waiting for orders."), while some pilot is Independent; each manual decision moves it. ### Step 10K.10 — Settings changes - Replace the backup code (CR-008 Steps 10H.8–10H.9) with **Export save** (shows the code with Copy and Share as file) and **Import save** (paste or open a file, then confirm). Keep the import warning about replacing the current company. diff --git a/memory-bank/change-requests/CR-021-independent-pilots.md b/memory-bank/change-requests/CR-021-independent-pilots.md index 55c0dfa..0ea7bdd 100644 --- a/memory-bank/change-requests/CR-021-independent-pilots.md +++ b/memory-bank/change-requests/CR-021-independent-pilots.md @@ -134,6 +134,7 @@ Removes "automation / standing routes and auto-accept rules" from the deferred l - Extend the resolution loop (CR-011 Step 10K.6) into a time-ordered event queue covering arrivals, rest endings, shop completions, board period starts, and Independent pilots' decision points (each decision point is turnaround time after the pilot becomes able to act). Each pilot action is executed through the normal engine commands. - Cap catch-up at 7 days; process in chunks with a progress indicator hook. - **Test:** Determinism: from the same save, catching up 8 hours in one go and in eight 1-hour pieces gives identical saves. Two Independent pilots at the same location never take the same contract; the one whose decision comes first (then lower pilot id) gets it. A pilot over the duty limit rests on arrival and resumes after rest. Catch-up past 7 days stops at 7 days. No pilot is ever dispatched in a way that fails normal validation. +- **Amended by CR-024:** Independent pilots start no new work at or after the fleet's sleep time (the last manual decision plus the threshold); arrivals, rest and shop ends still resolve, and the next manual decision wakes them from that moment. ### Step 19.5 — Engine: live play - While the app is open, the loop runs pilot decisions in real time. Add a client-to-engine signal for the contract currently open in the detail view; pilots skip it. @@ -151,6 +152,7 @@ Removes "automation / standing routes and auto-accept rules" from the deferred l - Catch-up shows "Catching up on your fleet…" when it takes more than half a second. - The "while you were away" screen gains the per-pilot logbook from §2, with company totals. - **Test:** Component tests: the chip counts down and disappears when the window ends; the indicator appears for slow catch-ups only; the logbook lists each pilot's jobs, deadheads, rests, XP, and current location, and totals match the sum of entries. +- **Amended by CR-024:** The "while you were away" screen opens with a banner when the fleet went quiet ("Your fleet went quiet on [date, time] after [24 h] without orders.") and a Back to work button; sleeping Independent pilots read "Waiting for orders". ### Step 19.8 — Balance - Extend the Phase 12 balance script with an "away" scenario: the player sets all hired pilots to Independent and closes the app for 8 real hours. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index 1bd4323..c94125b 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -783,6 +783,7 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran - **Amended by CR-013:** The player starts with two ships (Courier and Clipper) and a 66,600 cr loan; re-check every timing target with them. Targets: first Market purchase 45–75 minutes, first discovery under 90 minutes, loan paid off in under 4 hours of active play. Run two first-purchase policies, Lighter-first and Freighter-first; both must reach "own four ships" within 25 % of each other's time. - **Amended by CR-018:** Ships are on Manual refueling: the simulated player taps "Refuel here first" before a leg only when the best job needs more fuel than is aboard (or to go looking for work), instead of relying on Auto top-up. The deal's ships start full. The first Market purchase target is 30–95 minutes (user decision). The balance script also prints each ship's fuel share of the median payout per route (Step 18.1). - **Amended by CR-021:** An away scenario: after three hours of play the fleet is fixed, and for eight hours either the usual policy flies every ship or the hired pilots fly Independent while the avatar stays put. The Independent fleet must earn 40–80% of active play (median over the worlds), never buy fuel below the spending floor and never be stranded (Step 21.8). +- **Amended by CR-024:** Away, the fleet goes quiet 24 hours (the default threshold) after the window opens. A week away must earn no more than a day away plus the payouts of trips in flight at the sleep time (Step 24.7); the 8-hour scenario is under the threshold and unchanged. - **Amended by CR-019:** Boards are generated for each location's qualifying fleet and read with jobs the fleet can no longer complete hidden, as in the engine; a check confirms no run is ever shown a job its fleet can't complete. Before buying a ship, the simulated player keeps enough cash to fill every landed ship's tank. - **As built:** `packages/sim/balance/simulate.ts` (run with `pnpm --filter @deadhead/sim balance [seed] [hours]`), tested in `packages/sim/test/balance.test.ts` over seven world seeds: timing targets on the median of the Lighter-first runs (the default player buys the cheaper ship first), the four-ship comparison on both policies' medians, and every invariant in every run. Meeting the targets needed decisions 0.5.84 and 0.5.85 and the tuning in `DECISIONS.md` (Step 12.1). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 2c1f494..dccc0f0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -527,4 +527,6 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022 - [x] **Step 24.4 — Engine: waking and "Back to work".** New command `backToWork` (`POST /api/fleet/wake`, a manual decision): it sets the last decision to now and returns the player state, awake. Any manual decision does the same, and pilots waiting at that time decide from it. Tests (`packages/engine/test/fleet-sleep.test.ts`): after two days asleep, "Back to work" brings Independent trips again, none before the moment of waking; so does buying fuel. `packages/schema/test/routes.test.ts` lists it as manual. - [x] **Step 24.5 — Client: return banner, status, and setting.** `apps/web/src/components/FleetSleep.tsx`: `SleepBanner` at the top of "While you were away" ("Your fleet went quiet on [date, time] after [24 h] without orders." and Back to work) and `FleetAsleep` under the Bridge's header ("Fleet asleep — waiting for your orders", the same button), both only while `fleetSleep.asleep` and some pilot is Independent. `independentStatus` reads "Waiting for orders" while asleep (after flying, the shop and rest). Settings → Company: "Fleet goes quiet after" (12 h / 24 h / 48 h / 3 days) through the new `setSleepThreshold` command (one of the offered times; a manual decision). Tests: `apps/web/test/fleet-sleep.test.tsx` (the Bridge status asleep only, not awake or with no Independent pilot, and Back to work clears it; the banner on the away screen and its button; no banner awake; the pilot status; the setting saves 3 days and shows it checked; the labels); `packages/engine/test/fleet-sleep.test.ts` (the setting takes an offered time, wakes the fleet and refuses 36 h). Screenshots: `docs/screenshots/24-5-away-banner-{phone,desktop}.png`, `24-5-bridge-asleep-{phone,desktop}.png`, `24-5-settings-{phone,desktop}.png`. - [x] **Step 24.6 — Client: sleep notification (native).** `plannedNotifications` adds one notification at the sleep time ("Your pilots are waiting for orders."), on its own rather than in a minute's group, while it's ahead and some pilot is Independent (`sleepNotificationAt`). The schedule is rebuilt from the save after every change, so each manual decision moves it, and a new company or an import drops it; the Notifications toggle and the web's no-op scheduler apply as for the rest. The toggle now reads "Arrivals, rest, upgrades and a quiet fleet". Tests (`packages/engine/test/notifications.test.ts`): one at the decision plus 24 hours; a later decision moves it, still one; none without an Independent pilot, with notifications off, or for a new company; none once asleep. The web's no-op scheduler is checked in `apps/web/test/notifications.test.ts`. -- [ ] Step 24.7 — Plan amendments and regression +- [x] **Step 24.7 — Plan amendments and regression.** CR-024 §6 applied: plan 0.5 (0.5.136–0.5.139, recorded with the CR) and Step 12.1; CR-014 Steps 10K.5 (the catalog flag) and 10K.9 (the sleep notification); CR-021 Steps 19.4 (no new work while asleep) and 19.7 (the banner); CR-011 Step 10H.2 (the threshold setting). Balance: the away scenario sleeps 24 hours into the window and reports `payoutInFlightAtSleep`; the new check (`packages/sim/test/balance.test.ts`, every seed) has a week away earn no more than a day away plus those payouts (seed 12345: day 8,352,903 cr, week 8,519,338 cr, in flight 217,951 cr). The 8-hour scenario stays within 40–80% (58% for seed 12345). Full suite: typecheck, lint, 1469 unit tests and 10 e2e tests pass. + +**Phase 24 is complete; stopped for review.** Manual check for the user: set Settings → Company → "Fleet goes quiet after" to 12 h with an Independent pilot, leave the game, reopen after 13+ hours, and check the banner on "While you were away", the logbook (trips up to the sleep time and those that landed after), "Waiting for orders" on the pilot, and that Back to work starts the pilot again. diff --git a/packages/sim/balance/run.ts b/packages/sim/balance/run.ts index 80613f3..8c0222d 100644 --- a/packages/sim/balance/run.ts +++ b/packages/sim/balance/run.ts @@ -49,3 +49,22 @@ console.log( `ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` + `violations ${away.violations.length}`, ); + +// A week away (CR-024 Step 24.7): the fleet goes quiet 24 hours into the absence. +const absence = (hours: number) => + simulate({ + worldSeed, + policy: "lighter-first", + durationMs: (3 + hours) * MS_PER_HOUR, + scenario: { atMinute: 180, minutes: hours * 60, mode: "away" }, + }); +const day = absence(24); +const week = absence(7 * 24); +console.log("\n=== a week away vs a day away, fleet asleep after 24 h (CR-024) ==="); +console.log( + `day ${day.scenarioEarnings} cr, week ${week.scenarioEarnings} cr, ` + + `in flight at sleep ${week.payoutInFlightAtSleep} cr: ` + + (week.scenarioEarnings! <= day.scenarioEarnings! + week.payoutInFlightAtSleep! + ? "OK" + : "TOO MUCH"), +); diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index 881a176..849f4ae 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -77,7 +77,9 @@ export interface SimulationOptions { * The away scenario (CR-021 Step 21.8): play as usual until `atMinute`, then * for `minutes` either keep playing actively with the same fleet ("active"), * or leave the hired pilots to fly Independent while the player is away - * ("away"). Neither buys, upgrades nor claims goals in that window. + * ("away"). Neither buys, upgrades nor claims goals in that window. Away, + * the window's start is the player's last decision, so the fleet goes quiet + * `fleetSleep.defaultHours` into it (CR-024). */ scenario?: { atMinute: number; minutes: number; mode: "active" | "away" }; } @@ -123,6 +125,11 @@ export interface SimulationResult { uncompletableShown: number; /** The scenario window's earnings: credits gained plus loan repaid (Step 21.8); null without one. */ scenarioEarnings: number | null; + /** + * Away: the payouts of trips in flight when the fleet went quiet (CR-024 + * Step 24.7), the most it can earn after; null if the window ended first. + */ + payoutInFlightAtSleep: number | null; } interface SimShip extends ShipState { @@ -822,6 +829,9 @@ export function simulate(options: SimulationOptions): SimulationResult { const firstLookMs = config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE; const floor = config.independentPilots.defaultSpendingFloor; let away = false; + // The fleet goes quiet a threshold after the player's last decision (CR-024). + const sleepAt = windowStart + config.fleetSleep.defaultHours * MS_PER_HOUR; + let payoutInFlightAtSleep: number | null = null; let windowCredits = 0; let windowLoan = 0; // Every simulated pilot is hired at the Earth bay: that is their home HQ. @@ -912,7 +922,13 @@ export function simulate(options: SimulationOptions): SimulationResult { let acted = false; for (;;) { const due = pilots - .filter((p) => !p.isAvatar && p.nextDecisionAt != null && p.nextDecisionAt <= now) + .filter( + (p) => + !p.isAvatar && + p.nextDecisionAt != null && + p.nextDecisionAt <= now && + p.nextDecisionAt < sleepAt, + ) .sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0]; if (!due) return acted; const before = due.nextDecisionAt; @@ -940,6 +956,10 @@ export function simulate(options: SimulationOptions): SimulationResult { } } const inWindow = scenario !== undefined && now >= windowStart; + // Asleep from here: what is still flying is all the window can earn later. + if (away && payoutInFlightAtSleep === null && now >= sleepAt) { + payoutInFlightAtSleep = flights.reduce((sum, f) => sum + (f.record.contract?.payout ?? 0), 0); + } resolveArrivals(); for (let guard = 0; guard < 50; guard++) { let acted = false; @@ -985,5 +1005,6 @@ export function simulate(options: SimulationOptions): SimulationResult { jobsShown, uncompletableShown, scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null, + payoutInFlightAtSleep, }; } diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index b30bb0a..4ccccfe 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -141,3 +141,27 @@ describe("the away scenario (CR-021 Step 21.8)", () => { for (const run of awayRuns) expect(run.violations).toEqual([]); }); }); + +describe("a week away (CR-024 Step 24.7, plan 12.1)", () => { + const away = (worldSeed: number, hours: number) => + simulate({ + worldSeed, + policy: "lighter-first", + durationMs: (3 + hours) * 60 * 60_000, + scenario: { atMinute: 180, minutes: hours * 60, mode: "away" }, + }); + + it("earns no more in 7 days away than in 24 hours plus the trips in flight when the fleet went quiet", () => { + for (const seed of SEEDS) { + const day = away(seed, 24); + const week = away(seed, 7 * 24); + expect(week.payoutInFlightAtSleep, `seed ${seed}`).not.toBeNull(); + expect(week.scenarioEarnings!, `seed ${seed}`).toBeLessThanOrEqual( + day.scenarioEarnings! + week.payoutInFlightAtSleep!, + ); + // The pilots did work before going quiet. + expect(day.scenarioEarnings!, `seed ${seed}`).toBeGreaterThan(0); + expect(week.violations).toEqual([]); + } + }); +});