diff --git a/DECISIONS.md b/DECISIONS.md index 29eb2bf..2a3825f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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`). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9eb6bd3..c7b3158 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index d1ff3e7..c728d5d 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -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). */ diff --git a/packages/sim/src/completable.ts b/packages/sim/src/completable.ts new file mode 100644 index 0000000..889cda6 --- /dev/null +++ b/packages/sim/src/completable.ts @@ -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 | 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 | 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 | 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; + /** 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 }; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 2f9125b..b05dc78 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -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"; diff --git a/packages/sim/test/completable.test.ts b/packages/sim/test/completable.test.ts new file mode 100644 index 0000000..932c98c --- /dev/null +++ b/packages/sim/test/completable.test.ts @@ -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 => ({ + 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 => ({ + id, + modelId: "courier", + locationId: "earth", + ...over, + }); + const pilot = (id: string, over: Partial = {}): 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 }, + ]); + }); +});