Step 19.2: sim: generation keeps only completable jobs
CI / check (push) Failing after 58s
Details
CI / check (push) Failing after 58s
Details
With a fleet, boards keep only candidates some pair can complete, up to the board size or 200 attempts (decision 0.5.114). The balance player keeps enough cash to refuel landed ships before buying one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1b16063fdc
commit
e28597e09e
10
DECISIONS.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, number[]>();
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -142,7 +142,11 @@ const PURCHASE_ORDER: Record<PurchasePolicy, string[]> = {
|
|||
};
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue