135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
import { content, type Location, type ShipModel } from "@deadhead/content";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
checkRoute,
|
|
effectiveStats,
|
|
MS_PER_HOUR,
|
|
routeDistance,
|
|
travelTimeGameHours,
|
|
realToGame,
|
|
validateDispatch,
|
|
validateReposition,
|
|
wouldStrand,
|
|
type Contract,
|
|
type PilotState,
|
|
type RepositionProposal,
|
|
type ShipState,
|
|
type SimContent,
|
|
} from "../src/index";
|
|
|
|
const loc = (id: string) => content.location(id)!;
|
|
const now = Date.parse("2100-01-01T00:30:00Z");
|
|
const pilot: PilotState = {
|
|
id: "p1",
|
|
isAvatar: false,
|
|
assignedShipId: "s1",
|
|
inFlight: false,
|
|
skills: {},
|
|
restEndsAt: null,
|
|
};
|
|
|
|
/**
|
|
* Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can
|
|
* reach the Moon from Earth but can't climb back out. "moon-station" shares the
|
|
* Moon's orbit, so it is always in reach from there.
|
|
*/
|
|
const heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 };
|
|
const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" };
|
|
const fixture: SimContent = {
|
|
...content,
|
|
locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station],
|
|
shipModels: [
|
|
...content.shipModels,
|
|
{ ...content.shipModel("courier")!, id: "hopper", fuelRangeMillionKm: 0.45 },
|
|
],
|
|
};
|
|
const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false };
|
|
const proposal: RepositionProposal = {
|
|
ship: hopper,
|
|
pilot,
|
|
destinationId: "moon",
|
|
discoveredIds: new Set(["earth", "moon"]),
|
|
credits: 100_000,
|
|
now,
|
|
};
|
|
|
|
const contract: Contract = {
|
|
id: "c1",
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
cargoTypeId: "standard",
|
|
goodId: "consumer-goods",
|
|
shipper: "Pacific Orbital Logistics",
|
|
priority: false,
|
|
deliveryWindowGameHours: null,
|
|
tier: 0,
|
|
massTonnes: 10,
|
|
payout: 2000,
|
|
requiredSkill: null,
|
|
requiredLongHaulLevel: 0,
|
|
offeredAt: Date.parse("2100-01-01T00:00:00Z"),
|
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
|
firstContact: false,
|
|
};
|
|
|
|
describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
|
|
it("rejects a deadhead that would leave the ship unable to fly anywhere", () => {
|
|
expect(validateReposition(proposal, fixture)).toEqual({ ok: false, reason: "would-strand" });
|
|
});
|
|
|
|
it("passes the same trip once a reachable location is discovered", () => {
|
|
const discoveredIds = new Set(["earth", "moon", "moon-station"]);
|
|
expect(validateReposition({ ...proposal, discoveredIds }, fixture)).toEqual({ ok: true });
|
|
});
|
|
|
|
it("applies to contract dispatches too", () => {
|
|
const base = { ship: hopper, pilot, contract, tutorial: false, credits: 100_000, now };
|
|
expect(
|
|
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
|
|
).toEqual({ ok: false, reason: "would-strand" });
|
|
expect(
|
|
validateDispatch(
|
|
{ ...base, discoveredIds: new Set(["earth", "moon", "moon-station"]) },
|
|
fixture,
|
|
),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
it("never strands a base ship on a trip it is allowed to take between the five locations", () => {
|
|
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
|
|
const month = 30.44 * 24 * 3_600_000;
|
|
const ids = content.locations.map((l) => l.id);
|
|
const canLeave = (model: ShipModel, at: Location, t: number) =>
|
|
content.locations.some((next) => next.id !== at.id && checkRoute(model, at, next, t).ok);
|
|
for (const base of content.shipModels) {
|
|
const model = effectiveStats(base, {}, content);
|
|
for (const from of ids) {
|
|
for (const to of ids) {
|
|
if (from === to) continue;
|
|
for (let i = 0; i <= 16 * 12; i++) {
|
|
const t = start + i * month;
|
|
if (!checkRoute(model, loc(from), loc(to), t).ok) continue;
|
|
if (wouldStrand(model, loc(from), loc(to), 0, t, undefined, content)) continue;
|
|
const hours = travelTimeGameHours(
|
|
model,
|
|
routeDistance(loc(from), loc(to), t),
|
|
0,
|
|
content.config,
|
|
);
|
|
const arrival = t + hours * MS_PER_HOUR;
|
|
const label = `${model.id} ${from}→${to} month ${i}`;
|
|
expect(
|
|
canLeave(
|
|
model,
|
|
loc(to),
|
|
arrival + content.config.noStrandMarginGameHours * MS_PER_HOUR,
|
|
),
|
|
label,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|