Compare commits
2 Commits
86e511f181
...
1b16063fdc
| Author | SHA1 | Date |
|---|---|---|
|
|
1b16063fdc | |
|
|
ad1b8c76d9 |
12
DECISIONS.md
12
DECISIONS.md
|
|
@ -1615,3 +1615,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
## Step 18.5
|
## Step 18.5
|
||||||
|
|
||||||
- **The e2e flying helper refuels before every trip**, as a Manual player would. It had relied on Auto top-up, so e2e 12.6 failed on the Step 18.3 and 18.4 commits; the unit suites didn't cover it.
|
- **The e2e flying helper refuels before every trip**, as a Manual player would. It had relied on Auto top-up, so e2e 12.6 failed on the Step 18.3 and 18.4 commits; the unit suites didn't cover it.
|
||||||
|
|
||||||
|
## CR-019 recorded (Only Show Jobs the Fleet Can Complete)
|
||||||
|
|
||||||
|
- **Numbering:** received as "CR-018 — Only Show Jobs the Fleet Can Complete" for "Phase 18"; stored as **CR-019**, built as **Phase 19** (its 18.n = 19.n). Its decisions CR-018.D1–D4 are **0.5.114–0.5.117**. Its references: CR-005 → CR-007 (steps 10E.n → 10G.n), CR-006 → CR-009, CR-009 → CR-012, CR-011 → CR-014, CR-014 → CR-015; CR-002, CR-004 and plan steps 3.9, 3.11, 9.4 are unchanged.
|
||||||
|
- **It replaces this repo's CR-006 board rules**, which its numbering doesn't know about: the open and preview slots of decision 0.5.52 and the weight warning of 0.5.53.
|
||||||
|
|
||||||
|
## Step 19.1
|
||||||
|
|
||||||
|
- **One rule set, two uses**: `permanentFailure` checks the same things in the same order as `validateDispatch`, but it treats the tank as fillable where the origin sells fuel, because fuel on board is temporary there. `validateDispatch` keeps its "now" checks (idle, shop, rest, assignment, expiry, fuel on board) in front of and behind the permanent ones. The dispatch sheet still shows `validateDispatch`'s reason per pair, which is a superset.
|
||||||
|
- **"No pair here" also covers ships with no pilot**: the whole-fleet fallback applies whenever no ship-and-pilot pair qualifies, not only when no ship is there. A pilotless ship can't take a job, so a board built for it alone would be empty.
|
||||||
|
- **Whole-fleet pairs** are each ship with its assigned pilot and with every unassigned pilot, wherever they are, the most generous reading of "every owned ship and pilot pair".
|
||||||
|
- **"Arrives before the period ends" is strict** (`arrivalAt < periodEndsAt`).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Change Request CR-019 — Only Show Jobs the Fleet Can Complete
|
||||||
|
|
||||||
|
> **Numbering note (added when recorded):** this request arrived titled "CR-018 — Only Show Jobs the Fleet Can Complete" for "Phase 18". CR-018 (fuel tuning) and Phase 18 already existed, so it is stored as **CR-019** and done as **Phase 19**: every "18.n" below means **19.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-017" = Phase 13 and **CR-001 to CR-018**; "CR-002 open markets" and "CR-004" (rest) are the same numbers; "CR-005 cargo goods" = **CR-007** (its steps 10E.n are **10G.n**, so "10E.3, 10E.8" are **10G.3, 10G.8**); "CR-006 ship upgrades" = **CR-009**; "CR-009 new ships and range" = **CR-012**; "CR-011 local-first" = **CR-014**; "CR-014 fixed map and fuel tanks" = **CR-015**. Plan steps 3.9, 3.11 and 9.4 are the same numbers. Its new decisions CR-018.D1–D4 are recorded as **0.5.114 Completable boards, 0.5.115 Qualifying fleet, 0.5.116 Permanent checks only, 0.5.117 Mid-period changes**.
|
||||||
|
>
|
||||||
|
> **On recording:** this repo already has fleet-fitted boards from its own **CR-006 "Boards That Fit Your Fleet"** (decisions **0.5.52** and **0.5.53**), which the request's numbering doesn't include. In CR-006 every band keeps a configured number of open jobs (`boardOpenSlotsPerBand`) and bands no ship can carry shrink to a preview (`boardPreviewSlots`). The contract card also highlights a mass no ship there can carry (the weight warning, 0.5.53). This CR replaces all three: boards keep only completable jobs, and permanent-problem badges go. The "HQ" in §2 is a player bay at the location.
|
||||||
|
|
||||||
|
**Project:** Deadhead: Space Freight Tycoon
|
||||||
|
**Applies to:** a build with Phase 13 and CR-001 through CR-017 complete
|
||||||
|
**Do this:** as **Phase 18**
|
||||||
|
**Related docs:** `change-request-open-markets.md` (CR-002), `change-request-cargo-goods.md` (CR-005), `change-request-ship-upgrades.md` (CR-006), `change-request-new-ships-and-range.md` (CR-009), `change-request-local-first.md` (CR-011), `change-request-fixed-map-and-fuel-tanks.md` (CR-014), `DECISIONS.md`
|
||||||
|
|
||||||
|
This change request is written for the local-first engine (CR-011).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Contract boards fill up with jobs nobody in the player's fleet can take: too heavy for any ship there, Priority windows no ship can make, destinations out of range, or skill tiers no pilot has. They clutter the board and crowd out real work. From now on, **a board only offers jobs at least one of the player's ship-and-pilot pairs could take.**
|
||||||
|
|
||||||
|
## 2. Rules
|
||||||
|
|
||||||
|
### Which ships and pilots count
|
||||||
|
For a location's board, the **qualifying fleet** is:
|
||||||
|
|
||||||
|
- Owned ships **at** the location (idle, resting crew, or in the shop), plus owned ships **in flight to** the location that arrive before the board period ends.
|
||||||
|
- Each such ship's assigned pilot, or any unassigned pilot at that location if the player has an HQ there (so they could be assigned).
|
||||||
|
- **If no ship qualifies, use the whole fleet:** every owned ship and pilot pair, wherever they are. This keeps boards at empty locations useful for deciding where to deadhead.
|
||||||
|
|
||||||
|
### What "can complete" means
|
||||||
|
A job is completable if at least one qualifying pair passes every permanent check:
|
||||||
|
|
||||||
|
| Check | Uses |
|
||||||
|
|---|---|
|
||||||
|
| Cargo fits | Ship's effective capacity (CR-006) |
|
||||||
|
| Can land | Landing class |
|
||||||
|
| In range | Full-tank reach from the job's origin (CR-014); if the origin doesn't sell fuel, the fuel on board |
|
||||||
|
| Not stranded | No-strand rule (CR-009/CR-014) |
|
||||||
|
| Fast enough | Priority window, with handling time (CR-005) |
|
||||||
|
| Skilled enough | Cargo skill tier and Long Haul requirement (CR-005, plan 3.11) |
|
||||||
|
|
||||||
|
**Temporary conditions are ignored:** pilot resting, ship in the shop, not enough fuel on board right now (it can be refueled), and not enough credits. Those still show as reasons in the dispatch sheet.
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
- When a board is generated (first view in a period, CR-002), the generator produces candidate contracts as it does now and **keeps only completable ones** until the board is full or a maximum number of attempts is reached (default 200). A board may end up smaller than the configured size if the fleet can do very little there.
|
||||||
|
- First-contact contracts follow the same rule.
|
||||||
|
- The board stores the qualifying fleet's capabilities it was generated against, so results are deterministic and reproducible.
|
||||||
|
|
||||||
|
### When the fleet changes mid-period
|
||||||
|
- Stored boards stay as generated, but the board view **also filters at display time**: a job that's no longer completable (for example, the only capable ship left) is hidden.
|
||||||
|
- New capability (a new ship, an upgrade, a spent skill point) shows up on the next board refresh. With 10-minute periods (the rest/board tuning), that's a short wait.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
- Card badges for permanent problems ("TOO HEAVY", "TOO SLOW", skill too low) are removed: everything shown can be done by someone. Pairs that can't take a job are still disabled with their reason in the dispatch sheet.
|
||||||
|
- If filtering leaves a board empty: "No jobs here your fleet can take right now. New contracts in mm:ss." with the existing Rest and Deadhead options when a ship is idle there (CR-004).
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
- The tutorial board (always fits the loaner).
|
||||||
|
- How cargo classes, goods, Priority, and shippers are chosen (CR-005); filtering naturally shifts the mix toward what the fleet can carry.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
- The completability check is one pure function in `packages/sim`, reused by the generator, the display filter, and the dispatch sheet, so they never disagree.
|
||||||
|
- Save migration: stored boards from before this change are discarded and regenerate on next view.
|
||||||
|
- Update existing tests whose expectations change (board contents, card badges, the CR-005 class-mix baseline test, which should run with a "full capability" fleet fixture). Update `architecture.md` and `progress.md` as you go.
|
||||||
|
- Stop for the user's review at the end of Phase 18.
|
||||||
|
|
||||||
|
## 4. Decisions
|
||||||
|
|
||||||
|
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-018.D1–D4 here; note the mapping).
|
||||||
|
|
||||||
|
- **CR-018.D1 Completable boards.** Boards contain only jobs at least one qualifying ship-and-pilot pair can complete, per §2.
|
||||||
|
- **CR-018.D2 Qualifying fleet.** Ships at or arriving at the location, with their pilots or assignable pilots there; the whole fleet if none.
|
||||||
|
- **CR-018.D3 Permanent checks only.** Rest, shop, fuel on board, and credits don't affect completability.
|
||||||
|
- **CR-018.D4 Mid-period changes.** Display-time filtering hides jobs that stopped being completable; new capability appears at the next refresh.
|
||||||
|
|
||||||
|
## 5. Steps — Phase 18
|
||||||
|
|
||||||
|
### Step 18.1 — Sim: completability check
|
||||||
|
- Add a pure function: given a contract and a list of ship-and-pilot capability snapshots (effective stats, landing class, skills, location), return whether any pair can complete it and, per pair, the first failing permanent check.
|
||||||
|
- Add a function that builds the qualifying fleet for a location per §2.
|
||||||
|
- **Test:** One test per permanent check, each with a pair that fails only that check. A job is completable if any one pair passes. Resting, in-shop, low-fuel, and no-credit states don't change the result. A location with no ships returns the whole fleet as qualifying.
|
||||||
|
|
||||||
|
### Step 18.2 — Sim: generation keeps only completable jobs
|
||||||
|
- The board generator takes the qualifying fleet snapshot and keeps only completable candidates, up to the board size or the attempt limit. First-contact candidates are filtered the same way.
|
||||||
|
- **Test:** Over 1,000 boards with assorted fleet fixtures, no generated contract fails the completability check for its fleet. With only a level-0 avatar in a Courier at Earth, every board is full and every job is Standard, fits the Courier, and goes to the Moon. With a fleet that can do almost nothing, generation stops at the attempt limit and returns a smaller board without error. Same inputs still give identical boards. The CR-005 class-mix test passes with a full-capability fleet fixture.
|
||||||
|
|
||||||
|
### Step 18.3 — Engine: boards, filtering, and migration
|
||||||
|
- Board generation passes the qualifying fleet snapshot and stores it with the board. Board reads filter out jobs that are no longer completable for the current qualifying fleet.
|
||||||
|
- Save migration discards stored boards from before this change.
|
||||||
|
- **Test:** Generating a board, then selling or moving away the only ship that could take a heavy job, hides that job on the next read. Buying a bigger ship mid-period doesn't add jobs until the next period. An older save migrates with no stored boards, and boards regenerate on view.
|
||||||
|
|
||||||
|
### Step 18.4 — Client: cards and empty state
|
||||||
|
- Remove permanent-problem badges from contract cards. Keep the dispatch sheet's per-pair reasons.
|
||||||
|
- Add the empty-board message from §2.
|
||||||
|
- **Test:** Component test: no card shows TOO HEAVY, TOO SLOW, or a skill-too-low badge. An empty filtered board shows the message with the refresh countdown, plus Rest and Deadhead when a ship is idle there.
|
||||||
|
|
||||||
|
### Step 18.5 — Plan amendments and regression
|
||||||
|
- Apply the amendments in §6.
|
||||||
|
- Run the full suite.
|
||||||
|
- **Test:** CI is green. The Phase 12 balance script still meets all timing targets, and its simulated player never sees a job it can't complete. Manual check: with only the deal's Courier and Clipper, browse the Earth board and confirm every job can be dispatched with one of them.
|
||||||
|
|
||||||
|
## 6. Amendments
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| Plan 0.5 | Add CR-018.D1–D4. |
|
||||||
|
| Plan 3.9 | Board generation filters by the qualifying fleet (18.2). |
|
||||||
|
| Plan 9.4 | Cards drop permanent-problem badges; empty-board message (18.4). |
|
||||||
|
| CR-005 10E.3, 10E.8 | Class-mix test uses a full-capability fleet fixture; "TOO SLOW" card badge removed. |
|
||||||
|
| CR-002 | Stored boards also record the fleet snapshot (18.3). |
|
||||||
|
|
||||||
|
## 7. Out of scope
|
||||||
|
|
||||||
|
A "show all jobs" toggle, regenerating boards immediately when the fleet changes, hiding jobs based on credits or fuel on board, and anything else in `implementation-plan.md` §0.3.
|
||||||
|
|
@ -169,6 +169,10 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
||||||
111. **Deal ships full** (CR-018). The deal's Courier and Clipper start with full tanks, valued at Earth's fuel price (replaces the quarter tank chosen during CR-015).
|
111. **Deal ships full** (CR-018). The deal's Courier and Clipper start with full tanks, valued at Earth's fuel price (replaces the quarter tank chosen during CR-015).
|
||||||
112. **Manual by default** (CR-018). New ships, from the deal or bought, default to Manual refueling (replaces 0.5.97's Auto default); Auto top-up is unchanged and still available per ship. Existing saves move every ship to Manual.
|
112. **Manual by default** (CR-018). New ships, from the deal or bought, default to Manual refueling (replaces 0.5.97's Auto default); Auto top-up is unchanged and still available per ship. Existing saves move every ship to Manual.
|
||||||
113. **Low-fuel warning** (CR-018). A ship's Fleet card warns "Low fuel" when its tank can't fly its longest reachable leg from where it is (CR-015 §4, CR-016 §3).
|
113. **Low-fuel warning** (CR-018). A ship's Fleet card warns "Low fuel" when its tank can't fly its longest reachable leg from where it is (CR-015 §4, CR-016 §3).
|
||||||
|
114. **Completable boards** (CR-019). A location board contains only jobs at least one qualifying ship-and-pilot pair can complete (CR-019 §2). Replaces 0.5.52's open and preview slots.
|
||||||
|
115. **Qualifying fleet** (CR-019). Owned ships at the location, or in flight there and arriving before the board period ends, each with its assigned pilot, or with any unassigned pilot at the location when the player has a bay there; the whole fleet when no pair qualifies.
|
||||||
|
116. **Permanent checks only** (CR-019). Completable means passing cargo fit (effective capacity), landing, range (full-tank reach, or the fuel on board where the origin sells none), the no-strand rule, the Priority window and the cargo and Long Haul skills. Rest, the shop, fuel on board and credits don't count.
|
||||||
|
117. **Mid-period changes** (CR-019). A stored board keeps the fleet snapshot it was generated with; reads hide jobs the current qualifying fleet can no longer complete; new capability shows at the next board period. Replaces 0.5.53's weight warning.
|
||||||
|
|
||||||
### 0.6 Translating change requests written for the server (CR-014 §7)
|
### 0.6 Translating change requests written for the server (CR-014 §7)
|
||||||
|
|
||||||
|
|
@ -833,6 +837,12 @@ Steps 18.1–18.5 are specified in `memory-bank/change-requests/CR-018-fuel-tuni
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 19 — Only Show Jobs the Fleet Can Complete (CR-019)
|
||||||
|
|
||||||
|
Steps 19.1–19.5 are specified in `memory-bank/change-requests/CR-019-completable-boards.md` §5 (written there as 18.1–18.5), with its §6 amendments. Stop for review at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Definition of Done (Base Game)
|
## Definition of Done (Base Game)
|
||||||
|
|
||||||
- All step tests pass in CI.
|
- All step tests pass in CI.
|
||||||
|
|
|
||||||
|
|
@ -435,3 +435,13 @@ Spec: `memory-bank/change-requests/CR-018-fuel-tuning.md` (received as "CR-017
|
||||||
- [x] **Step 18.5 — Plan amendments and regression.** CR-018 §6 applied: plan 0.5.94, 0.5.96, 0.5.97 and Step 12.1; CR-015 §3 (refueling), its CR-014.D5, Steps 15.4 and 15.6; CR-013 Step 10M.3 (deal ships full). The e2e `flyUntil` helper now fills the tank before each trip (it had relied on Auto top-up, so e2e 12.6 failed from Step 18.3 until this fix), and `playToDeal` checks that both deal ships arrive full on Manual. Full suite: typecheck, lint, 1300 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min (under 10), first Market purchase 36.7 (30–95), discovery 36.6, loan paid off 123.9, four ships within 25%, no violations.
|
- [x] **Step 18.5 — Plan amendments and regression.** CR-018 §6 applied: plan 0.5.94, 0.5.96, 0.5.97 and Step 12.1; CR-015 §3 (refueling), its CR-014.D5, Steps 15.4 and 15.6; CR-013 Step 10M.3 (deal ships full). The e2e `flyUntil` helper now fills the tank before each trip (it had relied on Auto top-up, so e2e 12.6 failed from Step 18.3 until this fix), and `playToDeal` checks that both deal ships arrive full on Manual. Full suite: typecheck, lint, 1300 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min (under 10), first Market purchase 36.7 (30–95), discovery 36.6, loan paid off 123.9, four ships within 25%, no violations.
|
||||||
|
|
||||||
**Phase 18 is complete; stopped for review.** Manual check for the user: on a fresh company, accept the deal, check both ships are full and on Manual (Fleet → Fuel), fly several Earth–Moon jobs, and check credits grow; a ship short of fuel for its longest leg shows Low fuel on its Fleet card.
|
**Phase 18 is complete; stopped for review.** Manual check for the user: on a fresh company, accept the deal, check both ships are full and on Manual (Fleet → Fuel), fly several Earth–Moon jobs, and check credits grow; a ship short of fuel for its longest leg shows Low fuel on its Fleet card.
|
||||||
|
|
||||||
|
## Phase 19 — Only Show Jobs the Fleet Can Complete (CR-019)
|
||||||
|
|
||||||
|
Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "CR-018 … Phase 18"). New decisions recorded as plan §0.5.114–0.5.117.
|
||||||
|
|
||||||
|
- [x] **Step 19.1 — Sim: completability check.** `packages/sim/src/completable.ts`: `permanentFailure(contract, member, discoveredIds, content)` gives a pair's first failing permanent check: over-capacity, cannot-land, out-of-range, would-strand, too-slow, cargo-skill-too-low, long-haul-too-low. It uses the same rules and order as `validateDispatch`, with the tank counted full where the origin sells fuel and the fuel on board where it doesn't. `completability` returns whether any pair passes plus each pair's failure; `isCompletable` is the shorthand. `qualifyingFleet` builds the pairs for a location (ships there or landing before the period ends, with their pilot or any unassigned pilot there if the player has a bay there; else the whole fleet). `FleetMember` (now with `fuelFu`) moved here from `board.ts`. Tests (`packages/sim/test/completable.test.ts`): one per permanent check (landing and stranding via content with a hardened Earth and a dry Mars); a dry origin counts only the fuel on board; Cargo Bay upgrades count; any one pair passing makes a job completable, with per-pair failures; rest, the shop and an empty tank block dispatch but not completability; the qualifying fleet's local pairs, ships landing before and after the period ends, spare pilots only with a bay, the whole-fleet fallback, and upgrades and fuel carried into the snapshot.
|
||||||
|
- [ ] Step 19.2 — Sim: generation keeps only completable jobs
|
||||||
|
- [ ] Step 19.3 — Engine: boards, filtering, and migration
|
||||||
|
- [ ] Step 19.4 — Client: cards and empty state
|
||||||
|
- [ ] Step 19.5 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { checkRoute } from "./eligibility";
|
||||||
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
||||||
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
||||||
import { travelTimeGameHours } from "./travel";
|
import { travelTimeGameHours } from "./travel";
|
||||||
import { effectiveStats, type UpgradeLevels } from "./upgrades";
|
import { effectiveStats } from "./upgrades";
|
||||||
|
|
||||||
/** The static data the sim needs. The `content` bundle satisfies this. */
|
/** The static data the sim needs. The `content` bundle satisfies this. */
|
||||||
export interface SimContent {
|
export interface SimContent {
|
||||||
|
|
@ -49,13 +49,8 @@ export function standardCargo(content: SimContent): CargoType {
|
||||||
return standard;
|
return standard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One of the player's ships, with its pilot's skills (none without a pilot). */
|
export type { FleetMember } from "./completable";
|
||||||
export interface FleetMember {
|
import type { FleetMember } from "./completable";
|
||||||
modelId: string;
|
|
||||||
skills: SkillLevels;
|
|
||||||
/** Installed upgrades: boards fit the ship's effective capacity (CR-009). */
|
|
||||||
upgrades?: UpgradeLevels;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardInput {
|
export interface BoardInput {
|
||||||
/** The location whose freight market this is; every contract starts here (CR-002). */
|
/** The location whose freight market this is; every contract starts here (CR-002). */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import type { SimContent } from "./board";
|
||||||
|
import type { RealTime } from "./clock";
|
||||||
|
import type { Contract } from "./contract";
|
||||||
|
import {
|
||||||
|
contractTripGameHours,
|
||||||
|
findLocation,
|
||||||
|
findShipModel,
|
||||||
|
tripFuel,
|
||||||
|
wouldStrand,
|
||||||
|
type DispatchFailure,
|
||||||
|
} from "./dispatch";
|
||||||
|
import { checkRoute } from "./eligibility";
|
||||||
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
|
import { effectiveStats, type UpgradeLevels } from "./upgrades";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ship-and-pilot pair's lasting capabilities (decision 0.5.115): the ship's
|
||||||
|
* model and upgrades, its pilot's skills, and the fuel on board (which counts
|
||||||
|
* only where the job's origin sells no fuel).
|
||||||
|
*/
|
||||||
|
export interface FleetMember {
|
||||||
|
modelId: string;
|
||||||
|
skills: SkillLevels;
|
||||||
|
/** Installed upgrades: effective stats count (CR-009). */
|
||||||
|
upgrades?: UpgradeLevels;
|
||||||
|
/** Fuel on board; absent means a full tank. */
|
||||||
|
fuelFu?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permanent checks, in order (decision 0.5.116): the dispatch reasons that can't be waited out. */
|
||||||
|
export const PERMANENT_FAILURES = [
|
||||||
|
"over-capacity",
|
||||||
|
"cannot-land",
|
||||||
|
"out-of-range",
|
||||||
|
"would-strand",
|
||||||
|
"too-slow",
|
||||||
|
"cargo-skill-too-low",
|
||||||
|
"long-haul-too-low",
|
||||||
|
] as const satisfies readonly DispatchFailure[];
|
||||||
|
export type PermanentFailure = (typeof PERMANENT_FAILURES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first permanent check a pair fails for a contract, or null when it could
|
||||||
|
* fly it once rested, out of the shop and refuelled (decision 0.5.116). Same
|
||||||
|
* rules and order as `validateDispatch`, but the tank counts as full wherever
|
||||||
|
* the origin sells fuel.
|
||||||
|
*/
|
||||||
|
export function permanentFailure(
|
||||||
|
contract: Contract,
|
||||||
|
member: FleetMember,
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): PermanentFailure | null {
|
||||||
|
const model = effectiveStats(
|
||||||
|
findShipModel(member.modelId, content),
|
||||||
|
member.upgrades ?? {},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const origin = findLocation(contract.originId, content);
|
||||||
|
const destination = findLocation(contract.destinationId, content);
|
||||||
|
|
||||||
|
if (contract.massTonnes > model.cargoCapacityTonnes) return "over-capacity";
|
||||||
|
const route = checkRoute(model, origin, destination, content.config);
|
||||||
|
if (!route.ok) return route.reason;
|
||||||
|
// Where the origin sells fuel the tank can be filled first; elsewhere only what's aboard.
|
||||||
|
const available = origin.sellsFuel
|
||||||
|
? model.tankCapacityFu
|
||||||
|
: Math.min(member.fuelFu ?? model.tankCapacityFu, model.tankCapacityFu);
|
||||||
|
const leg = tripFuel(model, origin, destination, content.config);
|
||||||
|
if (leg > available) return "out-of-range";
|
||||||
|
if (wouldStrand(model, destination, available - leg, discoveredIds, content)) {
|
||||||
|
return "would-strand";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contract.priority &&
|
||||||
|
contract.deliveryWindowGameHours !== null &&
|
||||||
|
contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours
|
||||||
|
) {
|
||||||
|
return "too-slow";
|
||||||
|
}
|
||||||
|
const pilot = checkPilotEligibility(member.skills, contract);
|
||||||
|
return pilot.ok ? null : pilot.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Completability {
|
||||||
|
/** At least one pair passes every permanent check (decision 0.5.114). */
|
||||||
|
completable: boolean;
|
||||||
|
/** Per pair, in order: the first failing permanent check, or null. */
|
||||||
|
failures: (PermanentFailure | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any pair in `fleet` could complete `contract` (decisions 0.5.114, 0.5.116). */
|
||||||
|
export function completability(
|
||||||
|
contract: Contract,
|
||||||
|
fleet: readonly FleetMember[],
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): Completability {
|
||||||
|
const failures = fleet.map((m) => permanentFailure(contract, m, discoveredIds, content));
|
||||||
|
return { completable: failures.some((f) => f === null), failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand: true when some pair could complete the contract. */
|
||||||
|
export function isCompletable(
|
||||||
|
contract: Contract,
|
||||||
|
fleet: readonly FleetMember[],
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): boolean {
|
||||||
|
return fleet.some((m) => permanentFailure(contract, m, discoveredIds, content) === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An owned ship as the qualifying-fleet rule sees it. */
|
||||||
|
export interface FleetShip {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
upgrades?: UpgradeLevels;
|
||||||
|
fuelFu?: number;
|
||||||
|
/** Where it is; null in flight. */
|
||||||
|
locationId: string | null;
|
||||||
|
/** In flight: where to, and when it lands. */
|
||||||
|
destinationId?: string | null;
|
||||||
|
arrivalAt?: RealTime | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pilot as the qualifying-fleet rule sees it. */
|
||||||
|
export interface FleetPilot {
|
||||||
|
id: string;
|
||||||
|
skills: SkillLevels;
|
||||||
|
assignedShipId: string | null;
|
||||||
|
/** Where an unassigned pilot is; null otherwise. */
|
||||||
|
locationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualifyingFleetInput {
|
||||||
|
locationId: string;
|
||||||
|
/** In a stable order: the snapshot (and so the board) follows it. */
|
||||||
|
ships: readonly FleetShip[];
|
||||||
|
pilots: readonly FleetPilot[];
|
||||||
|
/** Locations with a player bay: unassigned pilots there can be assigned. */
|
||||||
|
bayLocationIds: ReadonlySet<string>;
|
||||||
|
/** The end of the board period: ships landing before then count. */
|
||||||
|
periodEndsAt: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualifyingFleet {
|
||||||
|
members: FleetMember[];
|
||||||
|
/** No pair qualified at the location, so this is the whole fleet. */
|
||||||
|
wholeFleet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (ship: FleetShip, pilot: FleetPilot): FleetMember => ({
|
||||||
|
modelId: ship.modelId,
|
||||||
|
skills: pilot.skills,
|
||||||
|
...(ship.upgrades ? { upgrades: ship.upgrades } : {}),
|
||||||
|
...(ship.fuelFu !== undefined ? { fuelFu: ship.fuelFu } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pairs a location's board is made for (decision 0.5.115): ships there, or
|
||||||
|
* landing there before the period ends, each with its assigned pilot, or with
|
||||||
|
* any unassigned pilot there when the player has a bay there. With no such
|
||||||
|
* pair, every ship with its pilot, or with any unassigned pilot, anywhere.
|
||||||
|
*/
|
||||||
|
export function qualifyingFleet(input: QualifyingFleetInput): QualifyingFleet {
|
||||||
|
const { locationId, ships, pilots } = input;
|
||||||
|
const unassigned = pilots.filter((p) => p.assignedShipId === null);
|
||||||
|
const pairsFor = (ship: FleetShip, spare: readonly FleetPilot[]) => [
|
||||||
|
...pilots.filter((p) => p.assignedShipId === ship.id).map((p) => member(ship, p)),
|
||||||
|
...spare.map((p) => member(ship, p)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const here = ships.filter(
|
||||||
|
(s) =>
|
||||||
|
s.locationId === locationId ||
|
||||||
|
(s.locationId === null &&
|
||||||
|
s.destinationId === locationId &&
|
||||||
|
s.arrivalAt != null &&
|
||||||
|
s.arrivalAt < input.periodEndsAt),
|
||||||
|
);
|
||||||
|
const spareHere = input.bayLocationIds.has(locationId)
|
||||||
|
? unassigned.filter((p) => p.locationId === locationId)
|
||||||
|
: [];
|
||||||
|
const local = here.flatMap((s) => pairsFor(s, spareHere));
|
||||||
|
if (local.length > 0) return { members: local, wholeFleet: false };
|
||||||
|
return { members: ships.flatMap((s) => pairsFor(s, unassigned)), wholeFleet: true };
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,22 @@ export {
|
||||||
} from "./fuel";
|
} from "./fuel";
|
||||||
|
|
||||||
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
||||||
|
|
||||||
|
export {
|
||||||
|
completability,
|
||||||
|
isCompletable,
|
||||||
|
PERMANENT_FAILURES,
|
||||||
|
permanentFailure,
|
||||||
|
qualifyingFleet,
|
||||||
|
} from "./completable";
|
||||||
|
export type {
|
||||||
|
Completability,
|
||||||
|
FleetPilot,
|
||||||
|
FleetShip,
|
||||||
|
PermanentFailure,
|
||||||
|
QualifyingFleet,
|
||||||
|
QualifyingFleetInput,
|
||||||
|
} from "./completable";
|
||||||
export type { RouteCheck } from "./eligibility";
|
export type { RouteCheck } from "./eligibility";
|
||||||
|
|
||||||
export { contractPayout } from "./contract";
|
export { contractPayout } from "./contract";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
completability,
|
||||||
|
isCompletable,
|
||||||
|
permanentFailure,
|
||||||
|
qualifyingFleet,
|
||||||
|
validateDispatch,
|
||||||
|
type Contract,
|
||||||
|
type FleetMember,
|
||||||
|
type FleetPilot,
|
||||||
|
type FleetShip,
|
||||||
|
type SimContent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const job = (over: Partial<Contract> = {}): Contract => ({
|
||||||
|
id: "j",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 10,
|
||||||
|
payout: 1000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: 0,
|
||||||
|
expiresAt: 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const courier: FleetMember = { modelId: "courier", skills: {} };
|
||||||
|
const clipper: FleetMember = { modelId: "clipper", skills: {} };
|
||||||
|
|
||||||
|
/** Content with one location changed (no ship lands vacuum-only, and every place sells fuel). */
|
||||||
|
function withLocation(id: string, change: object): SimContent {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
locations: content.locations.map((l) => (l.id === id ? { ...l, ...change } : l)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("completability check (Step 19.1, decisions 0.5.114, 0.5.116)", () => {
|
||||||
|
it("passes a pair that can fly the job", () => {
|
||||||
|
expect(permanentFailure(job(), courier, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on cargo that doesn't fit the ship's effective capacity", () => {
|
||||||
|
expect(permanentFailure(job({ massTonnes: 25 }), courier, undefined, content)).toBe(
|
||||||
|
"over-capacity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails where the ship can't land", () => {
|
||||||
|
// Earth demanding a hardened landing: nothing in the fleet can land there.
|
||||||
|
const hardEarth = withLocation("earth", { landingRequirement: "hardened" });
|
||||||
|
const toEarth = job({ originId: "moon", destinationId: "earth" });
|
||||||
|
expect(permanentFailure(toEarth, courier, undefined, hardEarth)).toBe("cannot-land");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails out of full-tank range", () => {
|
||||||
|
expect(permanentFailure(job({ destinationId: "mars" }), courier, undefined, content)).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the fuel on board where the origin sells none", () => {
|
||||||
|
const dryEarth = withLocation("earth", { sellsFuel: false });
|
||||||
|
expect(permanentFailure(job(), { ...courier, fuelFu: 1 }, undefined, dryEarth)).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
expect(permanentFailure(job(), { ...courier, fuelFu: 40 }, undefined, dryEarth)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the ship would be stranded at the destination", () => {
|
||||||
|
// Mars selling no fuel: a Clipper lands there with too little to leave.
|
||||||
|
const dryMars = withLocation("mars", { sellsFuel: false });
|
||||||
|
expect(permanentFailure(job({ destinationId: "mars" }), clipper, undefined, dryMars)).toBe(
|
||||||
|
"would-strand",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a Priority window the ship can't make", () => {
|
||||||
|
const rushed = job({ priority: true, deliveryWindowGameHours: 0.01 });
|
||||||
|
expect(permanentFailure(rushed, courier, undefined, content)).toBe("too-slow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a cargo tier above the pilot's skill", () => {
|
||||||
|
const heavy = job({
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
tier: 2,
|
||||||
|
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 1 } }, undefined, content),
|
||||||
|
).toBe("cargo-skill-too-low");
|
||||||
|
expect(
|
||||||
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 2 } }, undefined, content),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a Long Haul requirement above the pilot's level", () => {
|
||||||
|
expect(permanentFailure(job({ requiredLongHaulLevel: 1 }), courier, undefined, content)).toBe(
|
||||||
|
"long-haul-too-low",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts upgrades: Cargo Bay makes a heavier lot fit", () => {
|
||||||
|
const bay = content.upgrades.find((u) => u.stat === "cargoCapacityTonnes")!;
|
||||||
|
const upgraded = { ...courier, upgrades: { [bay.id]: 3 } };
|
||||||
|
expect(permanentFailure(job({ massTonnes: 22 }), upgraded, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is completable when any one pair passes, and reports each pair's first failure", () => {
|
||||||
|
const heavyLot = job({ massTonnes: 25 });
|
||||||
|
expect(completability(heavyLot, [courier, clipper], undefined, content)).toEqual({
|
||||||
|
completable: true,
|
||||||
|
failures: ["over-capacity", null],
|
||||||
|
});
|
||||||
|
expect(completability(heavyLot, [courier], undefined, content).completable).toBe(false);
|
||||||
|
expect(isCompletable(heavyLot, [], undefined, content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores rest, the shop, fuel on board and credits (decision 0.5.116)", () => {
|
||||||
|
const now = 1_000;
|
||||||
|
// The dispatch check refuses all of these for now; none of them is permanent.
|
||||||
|
const ship = {
|
||||||
|
id: "s",
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
inFlight: false,
|
||||||
|
tank: { fuelFu: 0, avgPrice: 8 },
|
||||||
|
};
|
||||||
|
const pilot = {
|
||||||
|
id: "p",
|
||||||
|
isAvatar: true,
|
||||||
|
assignedShipId: "s",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
restEndsAt: null,
|
||||||
|
};
|
||||||
|
const contract = job();
|
||||||
|
const check = (over: { ship?: object; pilot?: object }) =>
|
||||||
|
validateDispatch(
|
||||||
|
{
|
||||||
|
ship: { ...ship, ...over.ship },
|
||||||
|
pilot: { ...pilot, ...over.pilot },
|
||||||
|
contract,
|
||||||
|
tutorial: false,
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(check({})).toEqual({ ok: false, reason: "not-enough-fuel" });
|
||||||
|
expect(check({ pilot: { restEndsAt: now + 1 } })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "pilot-resting",
|
||||||
|
});
|
||||||
|
expect(check({ ship: { inShopUntil: now + 1 } })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "ship-in-shop",
|
||||||
|
});
|
||||||
|
// The same pair, empty tank and all, can complete it.
|
||||||
|
expect(permanentFailure(contract, { ...courier, fuelFu: 0 }, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qualifying fleet (Step 19.1, decision 0.5.115)", () => {
|
||||||
|
const PERIOD_END = 10_000;
|
||||||
|
const ship = (id: string, over: Partial<FleetShip> = {}): FleetShip => ({
|
||||||
|
id,
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const pilot = (id: string, over: Partial<FleetPilot> = {}): FleetPilot => ({
|
||||||
|
id,
|
||||||
|
skills: { [id]: 1 },
|
||||||
|
assignedShipId: null,
|
||||||
|
locationId: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const fleet = (input: {
|
||||||
|
locationId?: string;
|
||||||
|
ships: FleetShip[];
|
||||||
|
pilots: FleetPilot[];
|
||||||
|
bays?: string[];
|
||||||
|
}) =>
|
||||||
|
qualifyingFleet({
|
||||||
|
locationId: input.locationId ?? "earth",
|
||||||
|
ships: input.ships,
|
||||||
|
pilots: input.pilots,
|
||||||
|
bayLocationIds: new Set(input.bays ?? []),
|
||||||
|
periodEndsAt: PERIOD_END,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes ships at the location with their assigned pilots", () => {
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a"), ship("b", { modelId: "clipper", locationId: "moon" })],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
members: [{ modelId: "courier", skills: { pa: 1 } }],
|
||||||
|
wholeFleet: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts ships landing here before the period ends, not after", () => {
|
||||||
|
const flying = (id: string, arrivalAt: number) =>
|
||||||
|
ship(id, { locationId: null, destinationId: "earth", arrivalAt, modelId: "clipper" });
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a"), flying("soon", PERIOD_END - 1), flying("late", PERIOD_END + 1)],
|
||||||
|
pilots: [
|
||||||
|
pilot("pa", { assignedShipId: "a" }),
|
||||||
|
pilot("ps", { assignedShipId: "soon" }),
|
||||||
|
pilot("pl", { assignedShipId: "late" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.members.map((m) => m.skills)).toEqual([{ pa: 1 }, { ps: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pairs unassigned pilots here with the ships here, only where the player has a bay", () => {
|
||||||
|
const ships = [ship("a")];
|
||||||
|
const pilots = [pilot("spare", { locationId: "earth" })];
|
||||||
|
expect(fleet({ ships, pilots, bays: ["earth"] }).members).toEqual([
|
||||||
|
{ modelId: "courier", skills: { spare: 1 } },
|
||||||
|
]);
|
||||||
|
// No bay here: the spare can't be assigned, so nothing qualifies here.
|
||||||
|
expect(fleet({ ships, pilots }).wholeFleet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the whole fleet, wherever it is, when nothing qualifies here", () => {
|
||||||
|
const result = fleet({
|
||||||
|
locationId: "mars",
|
||||||
|
ships: [
|
||||||
|
ship("a"),
|
||||||
|
ship("b", { modelId: "clipper", locationId: null, destinationId: "moon", arrivalAt: 5 }),
|
||||||
|
],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
members: [
|
||||||
|
{ modelId: "courier", skills: { pa: 1 } },
|
||||||
|
{ modelId: "clipper", skills: { pb: 1 } },
|
||||||
|
],
|
||||||
|
wholeFleet: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries upgrades and fuel into the snapshot", () => {
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a", { upgrades: { "cargo-bay": 1 }, fuelFu: 12 })],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" })],
|
||||||
|
});
|
||||||
|
expect(result.members).toEqual([
|
||||||
|
{ modelId: "courier", skills: { pa: 1 }, upgrades: { "cargo-bay": 1 }, fuelFu: 12 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue