Step 19.1: sim: completability check
CI / check (push) Successful in 6m17s Details

permanentFailure, completability and qualifyingFleet: a job is
completable when some qualifying ship-and-pilot pair passes every
permanent check (decisions 0.5.114-0.5.116).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 20:32:51 -04:00
parent ad1b8c76d9
commit 1b16063fdc
6 changed files with 476 additions and 9 deletions

View File

@ -1620,3 +1620,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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`).

View File

@ -440,7 +440,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.
- [ ] Step 19.1 — Sim: completability check
- [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

View File

@ -15,7 +15,7 @@ import { checkRoute } from "./eligibility";
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
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. */
export interface SimContent {
@ -49,13 +49,8 @@ export function standardCargo(content: SimContent): CargoType {
return standard;
}
/** One of the player's ships, with its pilot's skills (none without a pilot). */
export interface FleetMember {
modelId: string;
skills: SkillLevels;
/** Installed upgrades: boards fit the ship's effective capacity (CR-009). */
upgrades?: UpgradeLevels;
}
export type { FleetMember } from "./completable";
import type { FleetMember } from "./completable";
export interface BoardInput {
/** The location whose freight market this is; every contract starts here (CR-002). */

View File

@ -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 };
}

View File

@ -26,6 +26,22 @@ export {
} from "./fuel";
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 { contractPayout } from "./contract";

View File

@ -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 },
]);
});
});