diff --git a/DECISIONS.md b/DECISIONS.md index 2a3825f..86d2e3e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1627,3 +1627,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"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`). + +## Step 19.2 + +- **CR-006's slot plan stays as the candidate source**: fit slots aim candidates at the fleet, so a lone Courier still fills its board in a few attempts. Open and preview slots now only add variety, because whatever they draw must still be completable. `boardPreviewSlots` and `boardOpenSlotsPerBand` keep their values. +- **Candidates go round the slots**: a rejected candidate's slot is tried again on the next pass, so bands the fleet can't carry give way to bands it can, and the board size holds. +- **The class-mix baseline stays fleet-free** (CR-019 §3 asks for a full-capability fleet): this repo's fit slots (CR-006) pick goods the crew can carry rather than drawing a class from the mix, so with any fleet the Earth mix has been Standard ~48%, Heavy ~20%, Dangerous ~10% since CR-006. Filtering is not the cause. The ±3-point test keeps checking class selection with no fleet, and a new test shows a full-capability fleet filters nothing out. +- **No fleet means no filter** in the sim API (tools, the class-mix test). The engine always passes one, and an empty fleet gets an empty board. +- **Contract ids number the kept jobs** (`origin:period:n`, n = 0…), as before. +- **The simulated player's fuel reserve** is now the larger of 5,000 cr and what filling every landed ship costs where it is. Under Manual refuelling, the flat 5,000 let a purchase leave a Clipper at Venus unable to buy fuel (seed 777). +- **The fuel-share check's pilot is skilled in everything**, so the check measures what fits the *ship*; an unskilled pilot would now see only local, low-paying jobs. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c7b3158..d56c9ec 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -441,7 +441,7 @@ Spec: `memory-bank/change-requests/CR-018-fuel-tuning.md` (received as "CR-017 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 +- [x] **Step 19.2 — Sim: generation keeps only completable jobs.** `generateContractBoard` with a fleet draws candidates slot by slot (CR-006's band plan), round and round. It keeps those `isCompletable` for the fleet, numbered in order kept, until the board is full or `config.boardMaxAttempts` (200) candidates have been tried; first contacts count only when kept. Without a fleet it is unfiltered as before (tools and the class-mix baseline). The balance simulator keeps a fuel reserve big enough to fill every landed ship before buying another; one world had stranded a Clipper at Venus after a purchase. The fuel-share check's pilot is skilled in everything: only the ship limits what fits, and now no route is above 25%, the Clipper's Mars → Earth included. Balance medians: deal 3.7 min, first purchase 36.7, discovery 36.6, loan 123.3, no violations. Tests (`packages/sim/test/board-completable.test.ts`): 1,000 boards over five assorted fleets and five origins never offer an uncompletable job; a level-0 avatar's Courier at Earth always gets 8 Standard Moon jobs it fits; a Courier with only Earth known gets short boards of Moon first contacts, without error; identical inputs give identical boards; first contacts are filtered too. The CR-006 tests now expect an empty board for an empty fleet, full Courier boards with no previews, and band counts kept for a fleet that can do everything. `board-goods.test.ts`: a full-capability fleet filters nothing. The Venus board test gives its pilot Long Haul 1. - [ ] Step 19.3 — Engine: boards, filtering, and migration - [ ] Step 19.4 — Client: cards and empty state - [ ] Step 19.5 — Plan amendments and regression diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 9d6d952..c0d9742 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -76,6 +76,8 @@ export const gameConfig: GameConfig = { // preview job; flyable bands keep one open job, the rest fit a ship and pilot. boardPreviewSlots: 1, boardOpenSlotsPerBand: 1, + // Boards keep only jobs the fleet can complete (CR-019): candidates tried at most. + boardMaxAttempts: 200, // Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5. longHaulCutoffsMillionKm: [100, 250, 400, 600, 800], xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 }, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index ea5712b..3ea49e4 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -314,6 +314,8 @@ export const gameConfigSchema = z boardPreviewSlots: nonNegativeInt, /** Contracts per flyable band left open (skill-gated, first contact) rather than fit (0.5.52). */ boardOpenSlotsPerBand: nonNegativeInt, + /** Candidate contracts a board tries before stopping short (decision 0.5.114). */ + boardMaxAttempts: positiveInt, /** One cut-off per Long Haul level (decision 0.5.7). */ longHaulCutoffsMillionKm: increasingList, xpPerDelivery: z.object({ diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index d03fe97..2dfc5c2 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -97,6 +97,7 @@ const validGameConfig = { ], boardPreviewSlots: 1, boardOpenSlotsPerBand: 1, + boardMaxAttempts: 200, longHaulCutoffsMillionKm: [100, 200, 300, 400, 500], xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 }, pilotLevelXpThresholds: [100, 250, 500], diff --git a/packages/engine/test/new-places.test.ts b/packages/engine/test/new-places.test.ts index 9b78723..fc0f9ba 100644 --- a/packages/engine/test/new-places.test.ts +++ b/packages/engine/test/new-places.test.ts @@ -71,6 +71,9 @@ describe("Venus, Io and Europa in the engine (Step 16.4)", () => { ...deliveredTank("clipper", "earth"), }, ); + // Every leg out of Venus is ~200M km: Long Haul 1, or its board has nothing + // this fleet can take (CR-019). + save.pilots.find((p) => p.id === owner.avatarId)!.skills = { "long-haul": 1 }; }); const contract = testContract({ destinationId: "venus-cloud-city", diff --git a/packages/sim/balance/fuel-share.ts b/packages/sim/balance/fuel-share.ts index 29f07de..b2a1b9a 100644 --- a/packages/sim/balance/fuel-share.ts +++ b/packages/sim/balance/fuel-share.ts @@ -36,12 +36,15 @@ function median(values: number[]): number { * and every route it can fly, the fuel a leg burns (fuel has no mass, so any * load burns the same) at the origin's price, against the median payout of * the contracts on that route that fit the ship, over generated boards with - * every location discovered and that ship as the fleet. + * every location discovered and that ship, with a pilot skilled in everything, + * as the fleet (boards keep only jobs the fleet can complete, CR-019). */ export function fuelShares(content: SimContent, options: FuelShareOptions = {}): FuelShare[] { const { periods = 40, worldSeeds = [12345, 777, 4242] } = options; const config: GameConfig = content.config; const discoveredIds = new Set(content.locations.map((l) => l.id)); + // A pilot with every skill maxed: only the ship limits which jobs fit (CR-019 filters boards by the fleet). + const skills = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel])); const rows: FuelShare[] = []; for (const model of content.shipModels.filter((m) => m.purchasable)) { const payouts = new Map(); @@ -54,7 +57,7 @@ export function fuelShares(content: SimContent, options: FuelShareOptions = {}): discoveredIds, periodIndex, worldSeed, - fleet: [{ modelId: model.id, skills: {} }], + fleet: [{ modelId: model.id, skills }], }, content, ); diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index c82cf4b..ef843e0 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -142,7 +142,11 @@ const PURCHASE_ORDER: Record = { }; const FIRST_SESSION_MS = 2 * 60 * MS_PER_MINUTE; const SAMPLE_EVERY_MS = 10 * MS_PER_MINUTE; -/** Cash kept back for fuel after buying a ship, so the fleet can keep flying. */ +/** + * Cash kept back for fuel after buying a ship, so the fleet can keep flying: + * at least this, and at least what filling every landed ship costs where it + * is (ships refuel by hand since CR-018). + */ const FUEL_RESERVE = 5_000; /** Upgrades only from spare cash: never more than this share of credits on hand. */ const UPGRADE_CASH_SHARE = 0.1; @@ -525,13 +529,24 @@ export function simulate(options: SimulationOptions): SimulationResult { } } + /** Cash to keep for fuel: filling every landed ship where it is, and never under FUEL_RESERVE. */ + function fuelReserve(): number { + const toFill = ships + .filter((s) => !s.inFlight && loc(s.locationId).sellsFuel) + .reduce((sum, s) => { + const capacity = effectiveStats(model(s.modelId), s.upgrades, content).tankCapacityFu; + return sum + (capacity - s.tank.fuelFu) * fuelPriceAt(loc(s.locationId), config); + }, 0); + return Math.max(FUEL_RESERVE, Math.ceil(toFill)); + } + /** The next planned Market ship, bought at the Earth bay with a pilot hired for it. */ function buyShips(): boolean { const bought = ships.filter((s) => !s.fromDeal).length; const next = purchaseOrder[bought]; if (!next) return false; const price = model(next).price + config.pilotHireCost; - if (credits < price + FUEL_RESERVE) return false; + if (credits < price + fuelReserve()) return false; credits -= price; const ship = newShip(next, config.deal.bayLocationId, false); pilots.push({ diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index c728d5d..fcb9af5 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -11,6 +11,7 @@ import type { import { MS_PER_MINUTE, type RealTime } from "./clock"; import { contractPayout, type Contract } from "./contract"; import { routeDistance } from "./distance"; +import { isCompletable } from "./completable"; import { checkRoute } from "./eligibility"; import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot"; import { createRng, deriveSeed, type Rng, type Seed } from "./rng"; @@ -58,7 +59,10 @@ export interface BoardInput { discoveredIds: ReadonlySet; periodIndex: number; worldSeed: Seed; - /** The player's ships (CR-006). Absent or empty: a board that ignores the fleet. */ + /** + * The qualifying fleet (decision 0.5.115): the board keeps only jobs some + * pair can complete (0.5.114). Absent: an unfiltered board (tools and tests). + */ fleet?: readonly FleetMember[]; } @@ -425,7 +429,11 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number { * Generates a location's contract board for one period (any discovered * location, bay or not, CR-002): `contractMassBands` decide how * many contracts fall in each ship-size mass band, shaped by the player's fleet - * when given (CR-006). Same inputs always give the same board. + * when given (CR-006). With a fleet, only jobs some pair can complete are kept + * (decision 0.5.114): candidates are drawn slot by slot, round and round, until + * the board is full or `boardMaxAttempts` candidates have been tried, so a + * fleet that can do little gets a smaller board. Same inputs always give the + * same board. */ export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] { const { location: origin, discoveredIds, periodIndex, worldSeed } = input; @@ -450,19 +458,17 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C // One fit slot per board is a first contact the fleet can fly, while anywhere // reachable is undiscovered (decision 0.5.85): exploring never waits on luck. let firstContactOffered = false; - return planSlots(crews, config).map(({ band, kind }, i) => { - const id = `${origin.id}:${periodIndex}:${i}`; + const candidate = ({ band, kind }: { band: MassBand; kind: SlotKind }, id: string) => { const explore = kind === "fit" && !firstContactOffered && undiscovered.length > 0 ? fitJob(rng, band, crews, origin, undiscovered, offeredAt, content) : null; - if (explore) firstContactOffered = true; const fit = explore ?? (kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null); if (fit) { const { model, ...job } = fit; - return buildContract( + const contract = buildContract( { id, origin, @@ -475,6 +481,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C }, content, ); + return { contract, explored: explore !== null }; } const guaranteed = kind !== "open"; const plain = guaranteed && discovered.length > 0; @@ -490,7 +497,7 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C band, content, ); - return buildContract( + const contract = buildContract( { id, origin, @@ -506,5 +513,26 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C }, content, ); - }); + return { contract, explored: false }; + }; + + const slots = planSlots(crews, config); + const fleet = input.fleet; + if (!fleet) + return slots.map((slot, i) => candidate(slot, `${origin.id}:${periodIndex}:${i}`).contract); + const board: Contract[] = []; + for ( + let attempt = 0; + board.length < slots.length && attempt < config.boardMaxAttempts; + attempt++ + ) { + const { contract, explored } = candidate( + slots[attempt % slots.length]!, + `${origin.id}:${periodIndex}:${board.length}`, + ); + if (!isCompletable(contract, fleet, discoveredIds, content)) continue; + board.push(contract); + if (explored) firstContactOffered = true; + } + return board; } diff --git a/packages/sim/test/board-completable.test.ts b/packages/sim/test/board-completable.test.ts new file mode 100644 index 0000000..d610951 --- /dev/null +++ b/packages/sim/test/board-completable.test.ts @@ -0,0 +1,142 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + completability, + generateContractBoard, + isCompletable, + type BoardInput, + type FleetMember, +} from "../src/index"; + +const { config } = content; +const loc = (id: string) => content.location(id)!; +const allIds = new Set(content.locations.map((l) => l.id)); +const maxed = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel])); +const avatarCourier: FleetMember = { modelId: "courier", skills: {} }; + +/** Assorted fleets: lone ships, mixed crews, upgrades, skills. */ +const FLEETS: FleetMember[][] = [ + [avatarCourier], + [avatarCourier, { modelId: "clipper", skills: { "expensive-cargo": 1 } }], + [{ modelId: "lighter", skills: { "heavy-cargo": 2 }, upgrades: { "cargo-bay": 2 } }], + [{ modelId: "freighter", skills: { "long-haul": 2, "dangerous-cargo": 1 } }], + [{ modelId: "bulk-hauler", skills: maxed }, avatarCourier], +]; + +describe("boards keep only completable jobs (Step 19.2, decision 0.5.114)", () => { + it("never offers a job the fleet can't complete, over 1,000 boards", () => { + let boards = 0; + const origins = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]; + for (const [f, fleet] of FLEETS.entries()) { + for (const origin of origins) { + for (let periodIndex = 0; periodIndex < 40; periodIndex++) { + const discoveredIds = periodIndex % 2 ? allIds : new Set(["earth", "moon", "mars"]); + const input: BoardInput = { + location: loc(origin), + discoveredIds, + periodIndex, + worldSeed: 1000 + f, + fleet, + }; + for (const c of generateContractBoard(input, content)) { + const { completable, failures } = completability(c, fleet, discoveredIds, content); + expect(completable, `${c.id} ${JSON.stringify(failures)}`).toBe(true); + } + boards++; + } + } + } + expect(boards).toBeGreaterThanOrEqual(1000); + }); + + it("gives a level-0 avatar in a Courier at Earth full boards of Standard Moon jobs it fits", () => { + for (let periodIndex = 0; periodIndex < 200; periodIndex++) { + const board = generateContractBoard( + { + location: loc("earth"), + discoveredIds: allIds, + periodIndex, + worldSeed: 7, + fleet: [avatarCourier], + }, + content, + ); + expect(board).toHaveLength(config.contractBoardSize); + for (const c of board) { + expect(c.cargoTypeId).toBe("standard"); + expect(c.massTonnes).toBeLessThanOrEqual(content.shipModel("courier")!.cargoCapacityTonnes); + expect(c.destinationId).toBe("moon"); + } + } + }); + + it("stops at the attempt limit with a smaller board when the fleet can do almost nothing", () => { + // From Mars a Courier reaches nowhere: nothing is completable. + const nothing = generateContractBoard( + { + location: loc("mars"), + discoveredIds: allIds, + periodIndex: 3, + worldSeed: 7, + fleet: [avatarCourier], + }, + content, + ); + expect(nothing).toEqual([]); + // Only Earth known: a Courier can take nothing but the odd first contact to the + // Moon, which random candidates hit only sometimes, so some boards come up short. + const known = new Set(["earth"]); + let short = 0; + for (let periodIndex = 0; periodIndex < 50; periodIndex++) { + const board = generateContractBoard( + { + location: loc("earth"), + discoveredIds: known, + periodIndex, + worldSeed: 7, + fleet: [avatarCourier], + }, + content, + ); + expect(board.length).toBeLessThanOrEqual(config.contractBoardSize); + if (board.length < config.contractBoardSize) short++; + for (const c of board) { + expect(c.destinationId).toBe("moon"); + expect(isCompletable(c, [avatarCourier], known, content)).toBe(true); + } + } + expect(short).toBeGreaterThan(0); + }); + + it("gives identical boards for identical inputs", () => { + for (const fleet of FLEETS) { + const input: BoardInput = { + location: loc("earth"), + discoveredIds: allIds, + periodIndex: 12, + worldSeed: 99, + fleet, + }; + expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content)); + } + }); + + it("filters first contacts the same way", () => { + // Only Earth and the Moon known: a Clipper may explore Mars or Venus, a Courier nowhere. + const known = new Set(["earth", "moon"]); + let clipperFirstContacts = 0; + for (let periodIndex = 0; periodIndex < 100; periodIndex++) { + const input = { location: loc("earth"), discoveredIds: known, periodIndex, worldSeed: 5 }; + const courierBoard = generateContractBoard({ ...input, fleet: [avatarCourier] }, content); + expect(courierBoard.some((c) => c.firstContact)).toBe(false); + const clipper: FleetMember = { modelId: "clipper", skills: { "long-haul": 1 } }; + for (const c of generateContractBoard({ ...input, fleet: [clipper] }, content)) { + if (c.firstContact) { + clipperFirstContacts++; + expect(isCompletable(c, [clipper], known, content)).toBe(true); + } + } + } + expect(clipperFirstContacts).toBeGreaterThan(0); + }); +}); diff --git a/packages/sim/test/board-fleet.test.ts b/packages/sim/test/board-fleet.test.ts index 4d11270..0f15850 100644 --- a/packages/sim/test/board-fleet.test.ts +++ b/packages/sim/test/board-fleet.test.ts @@ -6,6 +6,7 @@ import { checkRoute, contractTripGameHours, generateContractBoard, + isCompletable, type BoardInput, type Contract, type FleetMember, @@ -37,10 +38,8 @@ const floorOf = (band: { min: number }) => const courier: FleetMember = { modelId: "courier", skills: {} }; describe("boards that fit the fleet (CR-006)", () => { - it("ignores an empty fleet: the same board as with no fleet", () => { - expect(generateContractBoard({ ...base, fleet: [] }, content)).toEqual( - generateContractBoard(base, content), - ); + it("gives an empty fleet an empty board: nobody could take anything (CR-019)", () => { + expect(generateContractBoard({ ...base, fleet: [] }, content)).toEqual([]); }); it("is deterministic with a fleet", () => { @@ -48,17 +47,11 @@ describe("boards that fit the fleet (CR-006)", () => { expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content)); }); - it("gives a lone Courier mostly jobs it can fly, keeping one preview per bigger band", () => { - const previews = (bands.length - 1) * config.boardPreviewSlots; - const expectedFit = config.contractBoardSize - previews - config.boardOpenSlotsPerBand; + it("gives a lone Courier a full board of jobs it can fly, with no previews (CR-019)", () => { for (let p = 0; p < 200; p++) { const board = generateContractBoard({ ...base, periodIndex: p, fleet: [courier] }, content); expect(board).toHaveLength(config.contractBoardSize); - expect(board.filter((c) => fits(c, courier)).length).toBeGreaterThanOrEqual(expectedFit); - // Each band the Courier can't carry still shows a preview. - for (const band of bands.slice(1)) { - expect(board.some((c) => c.massTonnes >= band.min && c.massTonnes <= band.max)).toBe(true); - } + expect(board.every((c) => fits(c, courier))).toBe(true); } }); @@ -78,9 +71,11 @@ describe("boards that fit the fleet (CR-006)", () => { }); it("keeps the configured band counts when the fleet covers every band", () => { + // Pilots skilled in everything, so no candidate is turned away (CR-019). + const skills = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel])); const fleet: FleetMember[] = ["courier", "freighter", "bulk-hauler"].map((modelId) => ({ modelId, - skills: {}, + skills, })); const board = generateContractBoard({ ...base, fleet }, content); let slot = 0; @@ -91,10 +86,13 @@ describe("boards that fit the fleet (CR-006)", () => { } } for (const [i, band] of bands.entries()) { - const member = fleet[i]!; const inBand = board.filter((c) => c.massTonnes >= floorOf(band) && c.massTonnes <= band.max); - const fitting = inBand.filter((c) => fits(c, member)); - expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand); + // Every job is one this band's ship (or a bigger one) can take. + expect( + inBand.every((c) => + fleet.slice(i).some((m) => isCompletable(c, [m], discoveredIds, content)), + ), + ).toBe(true); } }); diff --git a/packages/sim/test/board-goods.test.ts b/packages/sim/test/board-goods.test.ts index e908419..fd43881 100644 --- a/packages/sim/test/board-goods.test.ts +++ b/packages/sim/test/board-goods.test.ts @@ -106,6 +106,24 @@ describe("goods in board generation (CR-007)", () => { } }); + it("filters nothing for a full-capability fleet: every board full, in slot order (CR-019)", () => { + // Class selection is measured above without a fleet: fit slots (CR-006) pick goods + // a crew can carry, not from the class mix. With a fleet that can do anything, + // completability turns no candidate away, so boards are as the slots made them. + const maxed = Object.fromEntries(content.skills.map((k) => [k.id, k.maxLevel])); + const fleet = content.shipModels + .filter((m) => m.purchasable) + .map((m) => ({ modelId: m.id, skills: maxed })); + for (const board of boards( + { ...earthInput, discoveredIds: baseFive, fleet }, + 300, + fivePlaces, + )) { + expect(board).toHaveLength(config.contractBoardSize); + expect(board.map((c) => Number(c.id.split(":").at(-1)))).toEqual(board.map((_, i) => i)); + } + }); + it("makes 10–20% of eligible contracts Priority, only on eligible goods, with the reference window", () => { let eligible = 0; let priority = 0; diff --git a/packages/sim/test/fuel-share.test.ts b/packages/sim/test/fuel-share.test.ts index f7da985..5c478b8 100644 --- a/packages/sim/test/fuel-share.test.ts +++ b/packages/sim/test/fuel-share.test.ts @@ -44,13 +44,14 @@ describe("fuel-to-payout check (CR-018 Step 18.1, decision 0.5.110)", () => { expect(row.medianPayout).toBeGreaterThan(0); }); - it("keeps fuel at most 25% of the median payout on every route but the Clipper's Mars → Earth (Step 18.2)", () => { + it("keeps fuel at most 25% of the median payout on every route (Step 18.2)", () => { // One price can't put every row in 15–25% (user decision, CR-018): only the top is gated. - // Few Mars → Earth jobs are small enough for the Clipper's 30 t hold, so their median is low. + // The Clipper's Mars → Earth was the one exception until boards kept only jobs the + // fleet can complete (CR-019): fitted boards offer it more jobs its 30 t hold takes. const over = rows .filter((r) => r.share > 0.25) .map((r) => `${r.modelId} ${r.originId} → ${r.destinationId}`); - expect(over).toEqual(["clipper mars → earth"]); + expect(over).toEqual([]); }); it("reports the share for each ship and route as a table", () => {