Step 19.1: sim: completability check
CI / check (push) Successful in 6m17s
Details
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:
parent
ad1b8c76d9
commit
1b16063fdc
|
|
@ -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.
|
- **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.
|
- **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`).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.2 — Sim: generation keeps only completable jobs
|
||||||
- [ ] Step 19.3 — Engine: boards, filtering, and migration
|
- [ ] Step 19.3 — Engine: boards, filtering, and migration
|
||||||
- [ ] Step 19.4 — Client: cards and empty state
|
- [ ] Step 19.4 — Client: cards and empty state
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { checkRoute } from "./eligibility";
|
||||||
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
|
||||||
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
||||||
import { travelTimeGameHours } from "./travel";
|
import { travelTimeGameHours } from "./travel";
|
||||||
import { effectiveStats, type UpgradeLevels } from "./upgrades";
|
import { effectiveStats } from "./upgrades";
|
||||||
|
|
||||||
/** The static data the sim needs. The `content` bundle satisfies this. */
|
/** The static data the sim needs. The `content` bundle satisfies this. */
|
||||||
export interface SimContent {
|
export interface SimContent {
|
||||||
|
|
@ -49,13 +49,8 @@ export function standardCargo(content: SimContent): CargoType {
|
||||||
return standard;
|
return standard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One of the player's ships, with its pilot's skills (none without a pilot). */
|
export type { FleetMember } from "./completable";
|
||||||
export interface FleetMember {
|
import type { FleetMember } from "./completable";
|
||||||
modelId: string;
|
|
||||||
skills: SkillLevels;
|
|
||||||
/** Installed upgrades: boards fit the ship's effective capacity (CR-009). */
|
|
||||||
upgrades?: UpgradeLevels;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardInput {
|
export interface BoardInput {
|
||||||
/** The location whose freight market this is; every contract starts here (CR-002). */
|
/** The location whose freight market this is; every contract starts here (CR-002). */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import type { SimContent } from "./board";
|
||||||
|
import type { RealTime } from "./clock";
|
||||||
|
import type { Contract } from "./contract";
|
||||||
|
import {
|
||||||
|
contractTripGameHours,
|
||||||
|
findLocation,
|
||||||
|
findShipModel,
|
||||||
|
tripFuel,
|
||||||
|
wouldStrand,
|
||||||
|
type DispatchFailure,
|
||||||
|
} from "./dispatch";
|
||||||
|
import { checkRoute } from "./eligibility";
|
||||||
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
|
import { effectiveStats, type UpgradeLevels } from "./upgrades";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ship-and-pilot pair's lasting capabilities (decision 0.5.115): the ship's
|
||||||
|
* model and upgrades, its pilot's skills, and the fuel on board (which counts
|
||||||
|
* only where the job's origin sells no fuel).
|
||||||
|
*/
|
||||||
|
export interface FleetMember {
|
||||||
|
modelId: string;
|
||||||
|
skills: SkillLevels;
|
||||||
|
/** Installed upgrades: effective stats count (CR-009). */
|
||||||
|
upgrades?: UpgradeLevels;
|
||||||
|
/** Fuel on board; absent means a full tank. */
|
||||||
|
fuelFu?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permanent checks, in order (decision 0.5.116): the dispatch reasons that can't be waited out. */
|
||||||
|
export const PERMANENT_FAILURES = [
|
||||||
|
"over-capacity",
|
||||||
|
"cannot-land",
|
||||||
|
"out-of-range",
|
||||||
|
"would-strand",
|
||||||
|
"too-slow",
|
||||||
|
"cargo-skill-too-low",
|
||||||
|
"long-haul-too-low",
|
||||||
|
] as const satisfies readonly DispatchFailure[];
|
||||||
|
export type PermanentFailure = (typeof PERMANENT_FAILURES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first permanent check a pair fails for a contract, or null when it could
|
||||||
|
* fly it once rested, out of the shop and refuelled (decision 0.5.116). Same
|
||||||
|
* rules and order as `validateDispatch`, but the tank counts as full wherever
|
||||||
|
* the origin sells fuel.
|
||||||
|
*/
|
||||||
|
export function permanentFailure(
|
||||||
|
contract: Contract,
|
||||||
|
member: FleetMember,
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): PermanentFailure | null {
|
||||||
|
const model = effectiveStats(
|
||||||
|
findShipModel(member.modelId, content),
|
||||||
|
member.upgrades ?? {},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const origin = findLocation(contract.originId, content);
|
||||||
|
const destination = findLocation(contract.destinationId, content);
|
||||||
|
|
||||||
|
if (contract.massTonnes > model.cargoCapacityTonnes) return "over-capacity";
|
||||||
|
const route = checkRoute(model, origin, destination, content.config);
|
||||||
|
if (!route.ok) return route.reason;
|
||||||
|
// Where the origin sells fuel the tank can be filled first; elsewhere only what's aboard.
|
||||||
|
const available = origin.sellsFuel
|
||||||
|
? model.tankCapacityFu
|
||||||
|
: Math.min(member.fuelFu ?? model.tankCapacityFu, model.tankCapacityFu);
|
||||||
|
const leg = tripFuel(model, origin, destination, content.config);
|
||||||
|
if (leg > available) return "out-of-range";
|
||||||
|
if (wouldStrand(model, destination, available - leg, discoveredIds, content)) {
|
||||||
|
return "would-strand";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contract.priority &&
|
||||||
|
contract.deliveryWindowGameHours !== null &&
|
||||||
|
contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours
|
||||||
|
) {
|
||||||
|
return "too-slow";
|
||||||
|
}
|
||||||
|
const pilot = checkPilotEligibility(member.skills, contract);
|
||||||
|
return pilot.ok ? null : pilot.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Completability {
|
||||||
|
/** At least one pair passes every permanent check (decision 0.5.114). */
|
||||||
|
completable: boolean;
|
||||||
|
/** Per pair, in order: the first failing permanent check, or null. */
|
||||||
|
failures: (PermanentFailure | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any pair in `fleet` could complete `contract` (decisions 0.5.114, 0.5.116). */
|
||||||
|
export function completability(
|
||||||
|
contract: Contract,
|
||||||
|
fleet: readonly FleetMember[],
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): Completability {
|
||||||
|
const failures = fleet.map((m) => permanentFailure(contract, m, discoveredIds, content));
|
||||||
|
return { completable: failures.some((f) => f === null), failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand: true when some pair could complete the contract. */
|
||||||
|
export function isCompletable(
|
||||||
|
contract: Contract,
|
||||||
|
fleet: readonly FleetMember[],
|
||||||
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
|
content: SimContent,
|
||||||
|
): boolean {
|
||||||
|
return fleet.some((m) => permanentFailure(contract, m, discoveredIds, content) === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An owned ship as the qualifying-fleet rule sees it. */
|
||||||
|
export interface FleetShip {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
upgrades?: UpgradeLevels;
|
||||||
|
fuelFu?: number;
|
||||||
|
/** Where it is; null in flight. */
|
||||||
|
locationId: string | null;
|
||||||
|
/** In flight: where to, and when it lands. */
|
||||||
|
destinationId?: string | null;
|
||||||
|
arrivalAt?: RealTime | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pilot as the qualifying-fleet rule sees it. */
|
||||||
|
export interface FleetPilot {
|
||||||
|
id: string;
|
||||||
|
skills: SkillLevels;
|
||||||
|
assignedShipId: string | null;
|
||||||
|
/** Where an unassigned pilot is; null otherwise. */
|
||||||
|
locationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualifyingFleetInput {
|
||||||
|
locationId: string;
|
||||||
|
/** In a stable order: the snapshot (and so the board) follows it. */
|
||||||
|
ships: readonly FleetShip[];
|
||||||
|
pilots: readonly FleetPilot[];
|
||||||
|
/** Locations with a player bay: unassigned pilots there can be assigned. */
|
||||||
|
bayLocationIds: ReadonlySet<string>;
|
||||||
|
/** The end of the board period: ships landing before then count. */
|
||||||
|
periodEndsAt: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualifyingFleet {
|
||||||
|
members: FleetMember[];
|
||||||
|
/** No pair qualified at the location, so this is the whole fleet. */
|
||||||
|
wholeFleet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (ship: FleetShip, pilot: FleetPilot): FleetMember => ({
|
||||||
|
modelId: ship.modelId,
|
||||||
|
skills: pilot.skills,
|
||||||
|
...(ship.upgrades ? { upgrades: ship.upgrades } : {}),
|
||||||
|
...(ship.fuelFu !== undefined ? { fuelFu: ship.fuelFu } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pairs a location's board is made for (decision 0.5.115): ships there, or
|
||||||
|
* landing there before the period ends, each with its assigned pilot, or with
|
||||||
|
* any unassigned pilot there when the player has a bay there. With no such
|
||||||
|
* pair, every ship with its pilot, or with any unassigned pilot, anywhere.
|
||||||
|
*/
|
||||||
|
export function qualifyingFleet(input: QualifyingFleetInput): QualifyingFleet {
|
||||||
|
const { locationId, ships, pilots } = input;
|
||||||
|
const unassigned = pilots.filter((p) => p.assignedShipId === null);
|
||||||
|
const pairsFor = (ship: FleetShip, spare: readonly FleetPilot[]) => [
|
||||||
|
...pilots.filter((p) => p.assignedShipId === ship.id).map((p) => member(ship, p)),
|
||||||
|
...spare.map((p) => member(ship, p)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const here = ships.filter(
|
||||||
|
(s) =>
|
||||||
|
s.locationId === locationId ||
|
||||||
|
(s.locationId === null &&
|
||||||
|
s.destinationId === locationId &&
|
||||||
|
s.arrivalAt != null &&
|
||||||
|
s.arrivalAt < input.periodEndsAt),
|
||||||
|
);
|
||||||
|
const spareHere = input.bayLocationIds.has(locationId)
|
||||||
|
? unassigned.filter((p) => p.locationId === locationId)
|
||||||
|
: [];
|
||||||
|
const local = here.flatMap((s) => pairsFor(s, spareHere));
|
||||||
|
if (local.length > 0) return { members: local, wholeFleet: false };
|
||||||
|
return { members: ships.flatMap((s) => pairsFor(s, unassigned)), wholeFleet: true };
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,22 @@ export {
|
||||||
} from "./fuel";
|
} from "./fuel";
|
||||||
|
|
||||||
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
||||||
|
|
||||||
|
export {
|
||||||
|
completability,
|
||||||
|
isCompletable,
|
||||||
|
PERMANENT_FAILURES,
|
||||||
|
permanentFailure,
|
||||||
|
qualifyingFleet,
|
||||||
|
} from "./completable";
|
||||||
|
export type {
|
||||||
|
Completability,
|
||||||
|
FleetPilot,
|
||||||
|
FleetShip,
|
||||||
|
PermanentFailure,
|
||||||
|
QualifyingFleet,
|
||||||
|
QualifyingFleetInput,
|
||||||
|
} from "./completable";
|
||||||
export type { RouteCheck } from "./eligibility";
|
export type { RouteCheck } from "./eligibility";
|
||||||
|
|
||||||
export { contractPayout } from "./contract";
|
export { contractPayout } from "./contract";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
completability,
|
||||||
|
isCompletable,
|
||||||
|
permanentFailure,
|
||||||
|
qualifyingFleet,
|
||||||
|
validateDispatch,
|
||||||
|
type Contract,
|
||||||
|
type FleetMember,
|
||||||
|
type FleetPilot,
|
||||||
|
type FleetShip,
|
||||||
|
type SimContent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const job = (over: Partial<Contract> = {}): Contract => ({
|
||||||
|
id: "j",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 10,
|
||||||
|
payout: 1000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: 0,
|
||||||
|
expiresAt: 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const courier: FleetMember = { modelId: "courier", skills: {} };
|
||||||
|
const clipper: FleetMember = { modelId: "clipper", skills: {} };
|
||||||
|
|
||||||
|
/** Content with one location changed (no ship lands vacuum-only, and every place sells fuel). */
|
||||||
|
function withLocation(id: string, change: object): SimContent {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
locations: content.locations.map((l) => (l.id === id ? { ...l, ...change } : l)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("completability check (Step 19.1, decisions 0.5.114, 0.5.116)", () => {
|
||||||
|
it("passes a pair that can fly the job", () => {
|
||||||
|
expect(permanentFailure(job(), courier, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on cargo that doesn't fit the ship's effective capacity", () => {
|
||||||
|
expect(permanentFailure(job({ massTonnes: 25 }), courier, undefined, content)).toBe(
|
||||||
|
"over-capacity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails where the ship can't land", () => {
|
||||||
|
// Earth demanding a hardened landing: nothing in the fleet can land there.
|
||||||
|
const hardEarth = withLocation("earth", { landingRequirement: "hardened" });
|
||||||
|
const toEarth = job({ originId: "moon", destinationId: "earth" });
|
||||||
|
expect(permanentFailure(toEarth, courier, undefined, hardEarth)).toBe("cannot-land");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails out of full-tank range", () => {
|
||||||
|
expect(permanentFailure(job({ destinationId: "mars" }), courier, undefined, content)).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the fuel on board where the origin sells none", () => {
|
||||||
|
const dryEarth = withLocation("earth", { sellsFuel: false });
|
||||||
|
expect(permanentFailure(job(), { ...courier, fuelFu: 1 }, undefined, dryEarth)).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
expect(permanentFailure(job(), { ...courier, fuelFu: 40 }, undefined, dryEarth)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the ship would be stranded at the destination", () => {
|
||||||
|
// Mars selling no fuel: a Clipper lands there with too little to leave.
|
||||||
|
const dryMars = withLocation("mars", { sellsFuel: false });
|
||||||
|
expect(permanentFailure(job({ destinationId: "mars" }), clipper, undefined, dryMars)).toBe(
|
||||||
|
"would-strand",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a Priority window the ship can't make", () => {
|
||||||
|
const rushed = job({ priority: true, deliveryWindowGameHours: 0.01 });
|
||||||
|
expect(permanentFailure(rushed, courier, undefined, content)).toBe("too-slow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a cargo tier above the pilot's skill", () => {
|
||||||
|
const heavy = job({
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
tier: 2,
|
||||||
|
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 1 } }, undefined, content),
|
||||||
|
).toBe("cargo-skill-too-low");
|
||||||
|
expect(
|
||||||
|
permanentFailure(heavy, { ...courier, skills: { "heavy-cargo": 2 } }, undefined, content),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a Long Haul requirement above the pilot's level", () => {
|
||||||
|
expect(permanentFailure(job({ requiredLongHaulLevel: 1 }), courier, undefined, content)).toBe(
|
||||||
|
"long-haul-too-low",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts upgrades: Cargo Bay makes a heavier lot fit", () => {
|
||||||
|
const bay = content.upgrades.find((u) => u.stat === "cargoCapacityTonnes")!;
|
||||||
|
const upgraded = { ...courier, upgrades: { [bay.id]: 3 } };
|
||||||
|
expect(permanentFailure(job({ massTonnes: 22 }), upgraded, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is completable when any one pair passes, and reports each pair's first failure", () => {
|
||||||
|
const heavyLot = job({ massTonnes: 25 });
|
||||||
|
expect(completability(heavyLot, [courier, clipper], undefined, content)).toEqual({
|
||||||
|
completable: true,
|
||||||
|
failures: ["over-capacity", null],
|
||||||
|
});
|
||||||
|
expect(completability(heavyLot, [courier], undefined, content).completable).toBe(false);
|
||||||
|
expect(isCompletable(heavyLot, [], undefined, content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores rest, the shop, fuel on board and credits (decision 0.5.116)", () => {
|
||||||
|
const now = 1_000;
|
||||||
|
// The dispatch check refuses all of these for now; none of them is permanent.
|
||||||
|
const ship = {
|
||||||
|
id: "s",
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
inFlight: false,
|
||||||
|
tank: { fuelFu: 0, avgPrice: 8 },
|
||||||
|
};
|
||||||
|
const pilot = {
|
||||||
|
id: "p",
|
||||||
|
isAvatar: true,
|
||||||
|
assignedShipId: "s",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
restEndsAt: null,
|
||||||
|
};
|
||||||
|
const contract = job();
|
||||||
|
const check = (over: { ship?: object; pilot?: object }) =>
|
||||||
|
validateDispatch(
|
||||||
|
{
|
||||||
|
ship: { ...ship, ...over.ship },
|
||||||
|
pilot: { ...pilot, ...over.pilot },
|
||||||
|
contract,
|
||||||
|
tutorial: false,
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(check({})).toEqual({ ok: false, reason: "not-enough-fuel" });
|
||||||
|
expect(check({ pilot: { restEndsAt: now + 1 } })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "pilot-resting",
|
||||||
|
});
|
||||||
|
expect(check({ ship: { inShopUntil: now + 1 } })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "ship-in-shop",
|
||||||
|
});
|
||||||
|
// The same pair, empty tank and all, can complete it.
|
||||||
|
expect(permanentFailure(contract, { ...courier, fuelFu: 0 }, undefined, content)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qualifying fleet (Step 19.1, decision 0.5.115)", () => {
|
||||||
|
const PERIOD_END = 10_000;
|
||||||
|
const ship = (id: string, over: Partial<FleetShip> = {}): FleetShip => ({
|
||||||
|
id,
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const pilot = (id: string, over: Partial<FleetPilot> = {}): FleetPilot => ({
|
||||||
|
id,
|
||||||
|
skills: { [id]: 1 },
|
||||||
|
assignedShipId: null,
|
||||||
|
locationId: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const fleet = (input: {
|
||||||
|
locationId?: string;
|
||||||
|
ships: FleetShip[];
|
||||||
|
pilots: FleetPilot[];
|
||||||
|
bays?: string[];
|
||||||
|
}) =>
|
||||||
|
qualifyingFleet({
|
||||||
|
locationId: input.locationId ?? "earth",
|
||||||
|
ships: input.ships,
|
||||||
|
pilots: input.pilots,
|
||||||
|
bayLocationIds: new Set(input.bays ?? []),
|
||||||
|
periodEndsAt: PERIOD_END,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes ships at the location with their assigned pilots", () => {
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a"), ship("b", { modelId: "clipper", locationId: "moon" })],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
members: [{ modelId: "courier", skills: { pa: 1 } }],
|
||||||
|
wholeFleet: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts ships landing here before the period ends, not after", () => {
|
||||||
|
const flying = (id: string, arrivalAt: number) =>
|
||||||
|
ship(id, { locationId: null, destinationId: "earth", arrivalAt, modelId: "clipper" });
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a"), flying("soon", PERIOD_END - 1), flying("late", PERIOD_END + 1)],
|
||||||
|
pilots: [
|
||||||
|
pilot("pa", { assignedShipId: "a" }),
|
||||||
|
pilot("ps", { assignedShipId: "soon" }),
|
||||||
|
pilot("pl", { assignedShipId: "late" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.members.map((m) => m.skills)).toEqual([{ pa: 1 }, { ps: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pairs unassigned pilots here with the ships here, only where the player has a bay", () => {
|
||||||
|
const ships = [ship("a")];
|
||||||
|
const pilots = [pilot("spare", { locationId: "earth" })];
|
||||||
|
expect(fleet({ ships, pilots, bays: ["earth"] }).members).toEqual([
|
||||||
|
{ modelId: "courier", skills: { spare: 1 } },
|
||||||
|
]);
|
||||||
|
// No bay here: the spare can't be assigned, so nothing qualifies here.
|
||||||
|
expect(fleet({ ships, pilots }).wholeFleet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the whole fleet, wherever it is, when nothing qualifies here", () => {
|
||||||
|
const result = fleet({
|
||||||
|
locationId: "mars",
|
||||||
|
ships: [
|
||||||
|
ship("a"),
|
||||||
|
ship("b", { modelId: "clipper", locationId: null, destinationId: "moon", arrivalAt: 5 }),
|
||||||
|
],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" }), pilot("pb", { assignedShipId: "b" })],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
members: [
|
||||||
|
{ modelId: "courier", skills: { pa: 1 } },
|
||||||
|
{ modelId: "clipper", skills: { pb: 1 } },
|
||||||
|
],
|
||||||
|
wholeFleet: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries upgrades and fuel into the snapshot", () => {
|
||||||
|
const result = fleet({
|
||||||
|
ships: [ship("a", { upgrades: { "cargo-bay": 1 }, fuelFu: 12 })],
|
||||||
|
pilots: [pilot("pa", { assignedShipId: "a" })],
|
||||||
|
});
|
||||||
|
expect(result.members).toEqual([
|
||||||
|
{ modelId: "courier", skills: { pa: 1 }, upgrades: { "cargo-bay": 1 }, fuelFu: 12 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue