Compare commits
2 Commits
8b7ab82c28
...
bf55eb8d9f
| Author | SHA1 | Date |
|---|---|---|
|
|
bf55eb8d9f | |
|
|
32f27b88c8 |
11
DECISIONS.md
11
DECISIONS.md
|
|
@ -1801,3 +1801,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **The shop line replaces the duty bar** while it shows, like an ordinary rest: duty is reset when the shop time ends, so the old hours would only mislead. It wins over an ordinary rest too, since that rest now ends with the shop at the latest.
|
||||
- **The test unassigns through the state, not a button**: the app has no unassign control (pilots are unassigned by the engine command), so the component test swaps in the unassigned pilot's state and refetches; the engine test checks that unassigning clears `restingInShopUntil`.
|
||||
|
||||
## CR-024 recorded (Fleet Sleep Mode)
|
||||
|
||||
- **Numbering:** received as "CR-022 — Fleet Sleep Mode" for "Phase 22"; stored as **CR-024**, built as **Phase 24** (its 22.n = 24.n), since CR-022 (Welcome Screens) and CR-023 (Shop Time Counts as Rest) exist. Its decisions CR-022.D1–D4 are **0.5.136–0.5.139**. Its references: CR-008 → CR-011, CR-011 → CR-014, CR-019 → CR-021; CR-004 and plan 12.1 are unchanged.
|
||||
- **"HQ" is the Bridge**, as in CR-021: the dashboard with the Dispatch Office panel. The HQs tab (CR-020) only lists bays.
|
||||
|
||||
## Step 24.1
|
||||
|
||||
- **"Set to the migration time" without a clock in the migration.** Migrations are pure functions of the save, so the 8 → 9 step leaves `lastManualDecisionAt` null and the engine's catch-up sets a null one to the time it runs, before anything else. That is the first time the engine opens the save (load or import), so the effect is the CR's: nobody's fleet is asleep the moment they update.
|
||||
- **The sleep settings are their own config block** (`fleetSleep`), not part of `independentPilots`, whose values are all checked as positive settings of a different kind.
|
||||
- **The threshold lives on the company**, beside the Dispatch Office and spending floor: it travels with the save, unlike device preferences.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
|
|||
expect(text).toContain("Build: test");
|
||||
// CR-014: the save, its format version and where it is stored; no player id.
|
||||
expect(text).toContain("Save: player-1");
|
||||
expect(text).toContain("Save version: 8");
|
||||
expect(text).toContain("Save version: 9");
|
||||
expect(text).toContain("Storage: unknown");
|
||||
expect(text).not.toContain("Player:");
|
||||
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
|
|||
|
||||
## Save document (`packages/schema/src/save.ts`, CR-014)
|
||||
|
||||
One document per company on the device, validated on every load and save: `saveVersion` (currently 8; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
|
||||
One document per company on the device, validated on every load and save: `saveVersion` (currently 9; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
|
||||
|
||||
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
# Change Request CR-024 — Fleet Sleep Mode
|
||||
|
||||
> **Numbering note (added when recorded):** this request arrived titled "CR-022 — Fleet Sleep Mode" for "Phase 22". CR-022 (Welcome Screens) and CR-023 (Shop Time Counts as Rest), and Phases 22 and 23, already existed, so it is stored as **CR-024** and done as **Phase 24**: every "22.n" below means **24.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-021" = Phase 13 and **CR-001 to CR-023**; "CR-004 pilot rest" is the same number; "CR-008 settings" = **CR-011** (Settings, the Notifications toggle's screen); "CR-011 local-first" = **CR-014** (the engine, the command catalog in `packages/schema/src/routes.ts`, the notification scheduler); "CR-019 independent pilots" = **CR-021** (Independent pilots, catch-up, the pilot logbook and the 8-hour away scenario). Plan 12.1 is the same step. Its new decisions CR-022.D1–D4 are recorded as **0.5.136 Manual decisions**, **0.5.137 Sleep**, **0.5.138 Waking** and **0.5.139 Sleep notification**.
|
||||
>
|
||||
> **On recording:** "HQ shows 'Fleet asleep'" is read as the Bridge (the dashboard, where the Dispatch Office panel is), as CR-021 read "HQ screen"; the HQs tab (CR-020) lists bays only. The Notifications toggle is CR-014's (Settings).
|
||||
|
||||
**Project:** Deadhead: Space Freight Tycoon
|
||||
**Applies to:** a build with Phase 13 and CR-001 through CR-021 complete
|
||||
**Do this:** as **Phase 22**
|
||||
**Related docs:** `change-request-pilot-rest.md` (CR-004), `change-request-settings.md` (CR-008), `change-request-local-first.md` (CR-011), `change-request-independent-pilots.md` (CR-019), `DECISIONS.md`
|
||||
|
||||
This change request is written for the local-first engine (CR-011).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
Playtesting: left alone for a week, Independent pilots (CR-019) kept running jobs the whole time. That makes absence too rewarding, removes any reason to check in, and makes catch-up long. The fleet should **go quiet when the boss hasn't given any orders for a while**, then wake up the moment the player makes a decision.
|
||||
|
||||
## 2. Rules
|
||||
|
||||
| Rule | Detail |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Manual decision | Any game command the player makes: dispatch, deadhead, refuel, buy, lease, hire, assign, spend a skill point, upgrade, change a pilot's mode or orders, claim a goal, repay the loan, accept the deal, buy the Dispatch Office. **Not** counted: opening the app, viewing screens, dismissing summaries, device preferences (CR-008 display/help), export/import. |
|
||||
| Sleep threshold | 24 real hours after the last manual decision by default. Player setting in Settings → Company: 12 h, 24 h, 48 h, or 3 days. |
|
||||
| Asleep | From the sleep time on, **Independent pilots start no new jobs or deadheads.** Trips already in flight finish normally (payout, XP, pilot share, loan repayment), and mandatory rest still happens. Pilots then wait with status "Waiting for orders". |
|
||||
| Unaffected | Trips the player dispatched by hand, upgrades in progress, and everything else that's already scheduled. |
|
||||
| Waking | The next manual decision wakes the fleet immediately: Independent pilots resume from that moment. The return screen also offers a **"Back to work"** button, which counts as a manual decision. |
|
||||
| Catch-up | Catch-up (CR-019) computes the sleep time from the saved last-decision time and stops starting new Independent work after it. Results stay deterministic. |
|
||||
| Notification (native only) | Each manual decision schedules one local notification at the sleep time, replacing the previous one: "Your pilots are waiting for orders." Canceled if a manual decision happens first. Respects the Notifications toggle (CR-011). |
|
||||
|
||||
### On return
|
||||
|
||||
The "while you were away" screen (CR-019 logbook) shows a banner when the fleet went quiet: "Your fleet went quiet on [date, time] after [24 h] without orders." with the **Back to work** button. The logbook covers activity up to the sleep time and the trips that finished after it.
|
||||
|
||||
## 3. Rules for this change
|
||||
|
||||
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
|
||||
- **One place decides what counts as a manual decision**: a flag on each engine command in the command catalog, so new commands must declare it.
|
||||
- Save migration: last manual decision time is set to the migration time; sleep threshold to the default.
|
||||
- Update `architecture.md` and `progress.md` as you go. Stop for the user's review at the end of Phase 22.
|
||||
|
||||
## 4. Decisions
|
||||
|
||||
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-022.D1–D4 here; note the mapping).
|
||||
|
||||
- **CR-022.D1 Manual decisions.** Defined per §2 and flagged per command in the command catalog.
|
||||
- **CR-022.D2 Sleep.** After the threshold (default 24 h, player-selectable), Independent pilots start no new work; in-flight trips and rest finish normally.
|
||||
- **CR-022.D3 Waking.** Any manual decision, including "Back to work", wakes the fleet immediately.
|
||||
- **CR-022.D4 Sleep notification.** One local notification at the sleep time, rescheduled on every manual decision.
|
||||
|
||||
## 5. Steps — Phase 22
|
||||
|
||||
### Step 22.1 — Content and save
|
||||
|
||||
- Add the default sleep threshold and the allowed options to config. Add to the save: last manual decision time and the chosen threshold. Save migration per §3.
|
||||
- **Test:** Config validates. A new company starts with the last manual decision time set to its creation and the default threshold. An older save migrates as described.
|
||||
|
||||
### Step 22.2 — Engine: manual decision flag
|
||||
|
||||
- Add a "manual decision" flag to every command in the command catalog per §2. Running a flagged command updates the last manual decision time.
|
||||
- **Test:** A catalog test fails if any command is missing the flag. Dispatching updates the time; changing a display preference or exporting a save doesn't. Commands run by Independent pilots (CR-019) never update it.
|
||||
|
||||
### Step 22.3 — Engine: sleep in live play and catch-up
|
||||
|
||||
- Independent pilots don't start new jobs or deadheads at or after the sleep time (last manual decision time + threshold). In-flight trips and rest resolve normally. Pilots that would act show "Waiting for orders".
|
||||
- **Test:** From a save with two Independent pilots and no manual decisions, catching up 7 days produces no new Independent trips after 24 hours; trips in flight at the 24-hour mark still pay out. Catching up in one go and in pieces gives identical results. A manual dispatch flown before the sleep time completes after it as normal. Changing the threshold to 48 hours moves the cutoff.
|
||||
|
||||
### Step 22.4 — Engine: waking and "Back to work"
|
||||
|
||||
- Add a "Back to work" command (flagged as a manual decision). Any manual decision after the sleep time wakes the fleet: Independent pilots become eligible to act again from that moment.
|
||||
- **Test:** After sleep, running "Back to work" lets Independent pilots take jobs again in the next decision round; so does any other manual decision, such as buying fuel.
|
||||
|
||||
### Step 22.5 — Client: return banner, status, and setting
|
||||
|
||||
- The "while you were away" screen shows the sleep banner and "Back to work" button when the fleet went quiet. HQ shows "Fleet asleep — waiting for your orders" with the same button while asleep.
|
||||
- Pilot status lines show "Waiting for orders" for sleeping Independent pilots.
|
||||
- Settings → Company: "Fleet goes quiet after: 12 h / 24 h / 48 h / 3 days".
|
||||
- **Test:** Component tests: the banner and HQ status appear only when asleep; the button calls the command and they disappear; the setting saves and changes the displayed threshold.
|
||||
|
||||
### Step 22.6 — Client: sleep notification (native)
|
||||
|
||||
- Schedule the notification per §2 through the CR-011 notification scheduler; reschedule on every manual decision; cancel on new company or import.
|
||||
- **Test:** With a mocked scheduler: a dispatch schedules one notification at last decision + threshold; a second manual decision replaces it; turning off notifications or starting a new company cancels it; on web, nothing is scheduled.
|
||||
|
||||
### Step 22.7 — Plan amendments and regression
|
||||
|
||||
- Apply the amendments in §6.
|
||||
- Run the full suite.
|
||||
- **Test:** CI is green. The CR-019 8-hour away scenario still meets its 40–80% target (it's under the default threshold). A new balance check: a 7-day absence earns no more than a 24-hour absence plus trips in flight at the sleep time. Manual check: set the threshold to 12 h, leave the game, reopen after 13+ hours, and confirm the banner, the logbook, and that "Back to work" restarts the pilots.
|
||||
|
||||
## 6. Amendments
|
||||
|
||||
| Where | Change |
|
||||
| --------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Plan 0.5 | Add CR-022.D1–D4. |
|
||||
| CR-011 | Command catalog gains the manual-decision flag; notification scheduler gains the sleep notification. |
|
||||
| CR-019 | Independent pilots stop starting new work while the fleet is asleep; logbook gains the sleep banner. |
|
||||
| CR-008 | Settings → Company gains the threshold option. |
|
||||
| Plan 12.1 | 7-day absence check added (22.7). |
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
Sleeping manually dispatched trips, gradual slowdowns before sleep, extra offline earnings caps, and anything else in `implementation-plan.md` §0.3.
|
||||
|
|
@ -191,6 +191,10 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
133. **Boards are for the player's pilots** (fix after CR-021). The qualifying fleet (0.5.115) counts only Manual pilots and the ships not assigned to an Independent pilot, so the player never sees jobs only an Independent pilot could fly; Independent pilots check each job against their own pair. With no Manual pair at all, the whole fleet counts.
|
||||
134. **Ready ships** (fix after CR-022). The Contracts screen's location chips and the Jobs tab badge count only ships a job could go out on now: idle, with an assigned pilot who isn't resting or Independent, and not in the shop. Where idle ships are docked but none is ready, the board says so and names the pilot who wakes first.
|
||||
135. **Shop time counts as rest** (CR-023). When an upgrade finishes, the pilot who was assigned to the ship for the whole shop time (never unassigned, so never flying another ship) has their duty reset to zero, and a rest that would end later ends then. A shop time shorter than a rest doesn't count. Independent pilots alike.
|
||||
136. **Manual decisions** (CR-024). Any game command the player makes counts (dispatch, deadhead, refuel, buy, lease, hire, assign, skill points, upgrades, pilot mode and orders, goals, the loan, the deal, the Dispatch Office, "Back to work"); opening the app, viewing, dismissing summaries, device preferences and export/import don't. Each command in the catalog declares it.
|
||||
137. **Sleep** (CR-024). From the last manual decision plus the threshold (default 24 real hours; 12 h, 24 h, 48 h or 3 days in Settings → Company), Independent pilots start no new jobs or deadheads; trips in flight and rest finish normally, and everything else already scheduled is unaffected.
|
||||
138. **Waking** (CR-024). Any manual decision, including "Back to work", wakes the fleet: Independent pilots act again from that moment.
|
||||
139. **Sleep notification** (CR-024). One local notification at the sleep time ("Your pilots are waiting for orders."), rescheduled by every manual decision, with the Notifications toggle.
|
||||
|
||||
### 0.6 Translating change requests written for the server (CR-014 §7)
|
||||
|
||||
|
|
@ -890,6 +894,12 @@ Steps 23.1–23.4 are specified in `memory-bank/change-requests/CR-023-shop-time
|
|||
|
||||
---
|
||||
|
||||
## Phase 24 — Fleet Sleep Mode (CR-024)
|
||||
|
||||
Steps 24.1–24.7 are specified in `memory-bank/change-requests/CR-024-fleet-sleep.md` §5 (written there as 22.1–22.7). Stop for review at the end.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (Base Game)
|
||||
|
||||
- All step tests pass in CI.
|
||||
|
|
|
|||
|
|
@ -516,3 +516,15 @@ Spec: `memory-bank/change-requests/CR-023-shop-time-rest.md` (received as "CR-02
|
|||
- [x] **Step 23.4 — Regression.** Full suite: typecheck, lint, 1432 unit tests and 10 e2e tests pass.
|
||||
|
||||
**Phase 23 is complete; stopped for review.** Manual check for the user: fly a pilot close to the duty limit, start an upgrade at an HQ, and check that the Pilots card reads "Resting while in the shop" and the pilot is ready to fly (no rest, zero duty) the moment the upgrade finishes.
|
||||
|
||||
## Phase 24 — Fleet Sleep Mode (CR-024)
|
||||
|
||||
Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022 … Phase 22"). New decisions recorded as plan §0.5.136–0.5.139.
|
||||
|
||||
- [x] **Step 24.1 — Content and save.** Config `fleetSleep` (default 24 h; options 12, 24, 48 and 72 h, rising, the default among them). Save version 9: `company.lastManualDecisionAt` and `company.sleepAfterHours`; a new company starts with its creation time and the default. The 8 → 9 migration sets the default and leaves the last decision null; the engine's catch-up sets a null one to the time it first runs on the save, which is the migration time in effect. Tests: `packages/content/test/config.test.ts`, `packages/schema/test/save.test.ts` (8 → 9), `packages/engine/test/fleet-sleep.test.ts` (a new company; an imported version-8 save is stamped with the import time).
|
||||
- [ ] Step 24.2 — Engine: manual decision flag
|
||||
- [ ] Step 24.3 — Engine: sleep in live play and catch-up
|
||||
- [ ] Step 24.4 — Engine: waking and "Back to work"
|
||||
- [ ] Step 24.5 — Client: return banner, status, and setting
|
||||
- [ ] Step 24.6 — Client: sleep notification (native)
|
||||
- [ ] Step 24.7 — Plan amendments and regression
|
||||
|
|
|
|||
|
|
@ -97,4 +97,5 @@ export const gameConfig: GameConfig = {
|
|||
maxCatchUpRealDays: 7,
|
||||
defaultSpendingFloor: 500,
|
||||
},
|
||||
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -344,6 +344,15 @@ export const gameConfigSchema = z
|
|||
/** Credits pilots never spend the company below, by default (0.5.127). */
|
||||
defaultSpendingFloor: positiveInt,
|
||||
}),
|
||||
/**
|
||||
* Fleet sleep (decision 0.5.137): real hours after the last manual decision
|
||||
* until Independent pilots stop starting new work, and the choices offered
|
||||
* in Settings → Company, shortest first.
|
||||
*/
|
||||
fleetSleep: z.object({
|
||||
defaultHours: z.number().positive(),
|
||||
optionsHours: z.array(z.number().positive()).min(1),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(config) =>
|
||||
|
|
@ -358,7 +367,19 @@ export const gameConfigSchema = z
|
|||
message: "The first-look window must be shorter than the board period",
|
||||
path: ["independentPilots", "firstLookRealMinutes"],
|
||||
},
|
||||
);
|
||||
)
|
||||
.refine(
|
||||
({ fleetSleep: { optionsHours } }) =>
|
||||
optionsHours.every((hours, i) => i === 0 || hours > optionsHours[i - 1]!),
|
||||
{
|
||||
message: "Sleep options must be in rising order, each once",
|
||||
path: ["fleetSleep", "optionsHours"],
|
||||
},
|
||||
)
|
||||
.refine(({ fleetSleep }) => fleetSleep.optionsHours.includes(fleetSleep.defaultHours), {
|
||||
message: "The default sleep threshold must be one of the options",
|
||||
path: ["fleetSleep", "defaultHours"],
|
||||
});
|
||||
export type GameConfig = z.infer<typeof gameConfigSchema>;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -130,3 +130,20 @@ describe("independent pilots config (Step 21.1, CR-021)", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fleet sleep config (Step 24.1, decision 0.5.137)", () => {
|
||||
const withSleep = (fleetSleep: { defaultHours: number; optionsHours: number[] }) =>
|
||||
gameConfigSchema.safeParse({ ...gameConfig, fleetSleep }).success;
|
||||
|
||||
it("defaults to 24 hours, from 12 h, 24 h, 48 h or 3 days", () => {
|
||||
expect(gameConfig.fleetSleep).toEqual({ defaultHours: 24, optionsHours: [12, 24, 48, 72] });
|
||||
});
|
||||
|
||||
it("rejects a default that isn't an option, and options out of order or repeated", () => {
|
||||
expect(withSleep({ defaultHours: 36, optionsHours: [12, 24, 48] })).toBe(false);
|
||||
expect(withSleep({ defaultHours: 24, optionsHours: [24, 12] })).toBe(false);
|
||||
expect(withSleep({ defaultHours: 24, optionsHours: [24, 24] })).toBe(false);
|
||||
expect(withSleep({ defaultHours: 0, optionsHours: [0] })).toBe(false);
|
||||
expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ const validGameConfig = {
|
|||
maxCatchUpRealDays: 7,
|
||||
defaultSpendingFloor: 500,
|
||||
},
|
||||
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
|
||||
tutorialLoanerShipModelId: "company-courier",
|
||||
tutorialRunsBeforeDeal: 5,
|
||||
tutorialXpMultiplier: 2,
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ export function catchUp(
|
|||
until: RealTime,
|
||||
options: CatchUpOptions,
|
||||
): CatchUpResult {
|
||||
// A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3).
|
||||
save.company.lastManualDecisionAt ??= until;
|
||||
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
||||
const decideLimit = Math.min(until, options.decideUntil);
|
||||
let resolved = 0;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export function startingSave(
|
|||
loanBalance: 0,
|
||||
dispatchOffice: false,
|
||||
npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor,
|
||||
// The company's creation is its first decision (CR-024 Step 24.1).
|
||||
lastManualDecisionAt: now,
|
||||
sleepAfterHours: content.config.fleetSleep.defaultHours,
|
||||
},
|
||||
ships: [],
|
||||
pilots: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encodeSave } from "../src/index";
|
||||
import { newTestApp, readSave, signUp, TEST_NOW } from "./support";
|
||||
|
||||
const HOUR = 60 * 60_000;
|
||||
|
||||
describe("fleet sleep in the save (Step 24.1, decisions 0.5.136, 0.5.137)", () => {
|
||||
it("starts a new company with its creation as the last decision and the default threshold", async () => {
|
||||
const { app } = newTestApp();
|
||||
await signUp(app);
|
||||
const { company } = await readSave(app);
|
||||
expect(company.lastManualDecisionAt).toBe(TEST_NOW);
|
||||
expect(company.sleepAfterHours).toBe(content.config.fleetSleep.defaultHours);
|
||||
});
|
||||
|
||||
it("stamps a migrated save's last decision with the time the engine first opens it", async () => {
|
||||
const { app } = newTestApp();
|
||||
await signUp(app);
|
||||
const current = await readSave(app);
|
||||
// As version 8 wrote it: no sleep fields.
|
||||
const { lastManualDecisionAt: _at, sleepAfterHours: _hours, ...company } = current.company;
|
||||
const v8 = { ...current, saveVersion: 8, company };
|
||||
|
||||
const device = newTestApp();
|
||||
device.clock.now = TEST_NOW + 30 * HOUR;
|
||||
await device.app.call("importSave", { code: await encodeSave(v8 as never) });
|
||||
const imported = (await readSave(device.app)).company;
|
||||
expect(imported.lastManualDecisionAt).toBe(TEST_NOW + 30 * HOUR);
|
||||
expect(imported.sleepAfterHours).toBe(24);
|
||||
});
|
||||
});
|
||||
|
|
@ -35,7 +35,8 @@ describe("Venus, Io and Europa in the engine (Step 16.4)", () => {
|
|||
const after = await readSave(app);
|
||||
expect(after.saveVersion).toBe(before.saveVersion);
|
||||
expect(after.discoveredLocations).toEqual(before.discoveredLocations);
|
||||
expect(after.company).toEqual(before.company);
|
||||
// The import stamps the fixture's migrated last decision (CR-024 §3).
|
||||
expect(after.company).toEqual({ ...before.company, lastManualDecisionAt: TEST_NOW });
|
||||
});
|
||||
|
||||
it("offers first contacts to Venus on Earth boards over time", () => {
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const savedDispatchSchema = z.object({
|
|||
});
|
||||
|
||||
/** The save version this build writes. Bump it with a new migration. */
|
||||
export const CURRENT_SAVE_VERSION = 8;
|
||||
export const CURRENT_SAVE_VERSION = 9;
|
||||
|
||||
export const saveDocumentSchema = z.object({
|
||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
||||
|
|
@ -198,6 +198,14 @@ export const saveDocumentSchema = z.object({
|
|||
dispatchOffice: z.boolean(),
|
||||
/** Credits Independent pilots never spend below (decision 0.5.127). */
|
||||
npcSpendingFloor: z.number().int().nonnegative(),
|
||||
/**
|
||||
* The player's last manual decision (decision 0.5.136): the fleet sleeps
|
||||
* `sleepAfterHours` later (0.5.137). Null only in a save just migrated to
|
||||
* version 9, until the engine first catches it up and sets it to that time.
|
||||
*/
|
||||
lastManualDecisionAt: timeSchema.nullable(),
|
||||
/** Real hours without orders before the fleet goes quiet (Settings → Company). */
|
||||
sleepAfterHours: z.number().positive(),
|
||||
}),
|
||||
ships: z.array(savedShipSchema),
|
||||
pilots: z.array(savedPilotSchema),
|
||||
|
|
@ -354,6 +362,22 @@ function shopPilot(save: Doc): Doc {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version 8 → 9 (CR-024): the default sleep threshold, and a last manual
|
||||
* decision the engine sets to the time it first opens the save (the migration
|
||||
* time, CR-024 §3), so nobody's fleet is asleep the moment they update.
|
||||
*/
|
||||
function fleetSleep(save: Doc): Doc {
|
||||
return {
|
||||
...save,
|
||||
company: {
|
||||
...(save.company as Doc),
|
||||
lastManualDecisionAt: null,
|
||||
sleepAfterHours: content.config.fleetSleep.defaultHours,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
||||
{ from: 1, migrate: addFuelTanks },
|
||||
{ from: 2, migrate: manualRefueling },
|
||||
|
|
@ -362,6 +386,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
|||
{ from: 5, migrate: catchUpState },
|
||||
{ from: 6, migrate: welcomeSeen },
|
||||
{ from: 7, migrate: shopPilot },
|
||||
{ from: 8, migrate: fleetSleep },
|
||||
];
|
||||
|
||||
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
|
|||
seed: 42,
|
||||
});
|
||||
return {
|
||||
saveVersion: 8,
|
||||
saveVersion: 9,
|
||||
saveId: "save-1",
|
||||
lastTimeSeen: T + 3_600_000,
|
||||
player: {
|
||||
|
|
@ -64,6 +64,9 @@ export function fullSave(): SaveDocument {
|
|||
loanBalance: 60_000,
|
||||
dispatchOffice: false,
|
||||
npcSpendingFloor: 500,
|
||||
// As the 8 → 9 migration leaves it: the engine sets it on first catch-up.
|
||||
lastManualDecisionAt: null,
|
||||
sleepAfterHours: 24,
|
||||
},
|
||||
ships: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
|
|||
describe("save document (Step 14.2)", () => {
|
||||
it("validates a fully populated save", () => {
|
||||
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
|
||||
expect(CURRENT_SAVE_VERSION).toBe(8);
|
||||
expect(CURRENT_SAVE_VERSION).toBe(9);
|
||||
});
|
||||
|
||||
it("rejects an invalid save with a readable error naming the field", () => {
|
||||
|
|
@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => {
|
|||
if (!result.ok) throw new Error(result.error);
|
||||
const { save } = result;
|
||||
const before = fullSave();
|
||||
expect(save.saveVersion).toBe(8);
|
||||
expect(save.saveVersion).toBe(9);
|
||||
// The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base
|
||||
// price, 5 cr/fu since CR-018 (decision 0.5.110).
|
||||
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" });
|
||||
|
|
@ -116,7 +116,7 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => {
|
|||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const { save } = result;
|
||||
expect(save.saveVersion).toBe(8);
|
||||
expect(save.saveVersion).toBe(9);
|
||||
expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual"));
|
||||
expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual(
|
||||
before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]),
|
||||
|
|
@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
|
|||
for (const board of old.contractBoards) delete board.fleet;
|
||||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
expect(result.save.saveVersion).toBe(8);
|
||||
expect(result.save.saveVersion).toBe(9);
|
||||
expect(result.save.contractBoards).toEqual([]);
|
||||
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
|
||||
});
|
||||
|
|
@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => {
|
|||
}
|
||||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
expect(result.save.saveVersion).toBe(8);
|
||||
expect(result.save.saveVersion).toBe(9);
|
||||
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
|
||||
for (const pilot of result.save.pilots) {
|
||||
expect(pilot.mode).toBe("manual");
|
||||
|
|
@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
|
|||
delete old.player.welcomeSeen;
|
||||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
expect(result.save.saveVersion).toBe(8);
|
||||
expect(result.save.saveVersion).toBe(9);
|
||||
expect(result.save.player.welcomeSeen).toBe(true);
|
||||
expect(result.save).toEqual(before);
|
||||
});
|
||||
|
|
@ -207,9 +207,27 @@ describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => {
|
|||
for (const ship of old.ships) delete ship.shopPilotId;
|
||||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
expect(result.save.saveVersion).toBe(8);
|
||||
expect(result.save.saveVersion).toBe(9);
|
||||
// Including the ship still in the shop: its upgrade finishes without the rest.
|
||||
expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null));
|
||||
expect(result.save).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => {
|
||||
it("sets the default threshold and leaves the last decision for the engine to stamp", () => {
|
||||
const before = fullSave();
|
||||
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
|
||||
company: Record<string, unknown>;
|
||||
};
|
||||
old.saveVersion = 8;
|
||||
delete old.company.lastManualDecisionAt;
|
||||
delete old.company.sleepAfterHours;
|
||||
const result = migrateSave(old);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
expect(result.save.saveVersion).toBe(9);
|
||||
expect(result.save.company.lastManualDecisionAt).toBeNull();
|
||||
expect(result.save.company.sleepAfterHours).toBe(24);
|
||||
expect(result.save).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue