Step 15.2: sim: static positions and distances
CI / check (push) Successful in 5m44s Details

Positions, distances, range checks, trip times, fuel and the no-strand
rule no longer take a time argument; the 48-hour no-strand margin goes
with it. Tests now check that a route's pay, trip time and fuel are the
same whenever it is offered or flown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 00:54:57 -04:00
parent d6bca5b1a2
commit 0a1ca1d3ea
57 changed files with 261 additions and 421 deletions

View File

@ -1445,3 +1445,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Ranges moved into CR-015 §4's bands here**, ahead of 15.3's tank conversion, because the fixed map otherwise breaks the game at once (the deal's Clipper, at 198M km, couldn't reach Mars at 201). Chosen with margins inside each band: Clipper 250 (band 201–302 from the §4 cells), Freighter 460 (402–515), Courier tanks +1,400 %/level (level 2 must stay under 154 so it can't reach Mars from the Moon; level 3 at least 201). 15.3 turns these into tank capacity ÷ burn rate with the same reach.
- **Lighter price 42,400 → 36,000** (balance re-check, decision 0.5.84's targets): with Mars always 201M km away, purchases land on multiples of the Clipper's ~43-minute first Mars leg, so the median first Market purchase was 86.7 min, outside 45–75. Of the knobs tried (Clipper speed, first-contact pay, Lighter price), the price alone fixes it without touching the reach bands: first purchase 53.3 min, discovery 43.7, loan paid off 135 min, four ships 115 (Lighter-first) vs 130 (Freighter-first). 38,000 was too high (91.9) and 34,000 pushed Lighter-first four ships to 156; 36,000 sits between.
- **No base ship can strand on the fixed map** (every destination can reach some discovered place back), so the would-strand tests use the one case that still can: a Freighter with maxed tanks flies Moon→Jupiter (867 ≥ 792) but from Jupiter (667) reaches neither Earth nor the Moon while Mars and Ceres are undiscovered.
## Step 15.2
- **Every time argument that only located planets is removed**, not just those on `locationPosition` and `routeDistance`: `checkRoute`, `tripFuelCost`, `contractTripGameHours`, `priorityWindow`, `wouldStrand` and the web helpers built on them. Keeping unused time parameters would invite callers to think the answer depends on when.
- **`wouldStrand` takes only the ship, the destination and what is discovered**: its origin, cargo mass and departure time existed to compute the arrival time and the planets' positions then. The configured 48-game-hour margin (`noStrandMarginGameHours`, decision 0.5.79) is removed with them; 15.5 adds fuel to the rule.
- **The Market's reach keeps its shape for one step**: `shipReach` now checks each leg once, so "other dates" is always empty; the field and its "Other dates" line go together in 15.8, the step that removes date wording from the client.

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim";
import { expect, type Page } from "@playwright/test";
/**
@ -109,10 +109,9 @@ export function nextMarsWindow(from: number): number {
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
const gameDay = (24 * 3_600_000) / content.config.timeCompression;
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
const g = realToGame(t, content.config);
if (
checkRoute(clipper, earth, mars, g).ok &&
!wouldStrand(clipper, earth, mars, 30, g, new Set(["earth", "moon", "mars"]), content)
checkRoute(clipper, earth, mars).ok &&
!wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content)
) {
// Well inside the window: a few game days in.
return t + 3 * gameDay;

View File

@ -63,14 +63,14 @@ export function PriorityChip({ windowHours }: { windowHours: number }) {
* True when a Priority contract has idle ships at its origin and none of them
* can make the window (CR-007). Display only; the sim decides at dispatch.
*/
export function tooSlowForAll(state: PlayerState, contract: Contract, now: number): boolean {
export function tooSlowForAll(state: PlayerState, contract: Contract): boolean {
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
if (idle.length === 0) return false;
// Effective stats: an engines upgrade can make the window (CR-009).
return idle.every(
(ship) =>
contractTripGameHours(shipStatsOf(ship), contract, now, content) >
contractTripGameHours(shipStatsOf(ship), contract, content) >
contract.deliveryWindowGameHours!,
);
}

View File

@ -4,11 +4,9 @@ import {
checkRoute,
effectiveStats,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type EffectiveStats,
type RealTime,
type ShipState,
} from "@deadhead/sim";
@ -26,26 +24,21 @@ export function shipModel(id: string): ShipModel {
return found;
}
/** Route distance if departing at real time `now`, in million km. */
export function distanceAt(originId: string, destinationId: string, now: number): number {
return routeDistance(
location(originId),
location(destinationId),
realToGame(now, content.config),
);
/** Route distance in million km; fixed (decision 0.5.93). */
export function distanceAt(originId: string, destinationId: string): number {
return routeDistance(location(originId), location(destinationId));
}
/** Trip duration in real milliseconds for a ship carrying `massTonnes`, departing at `now`. */
/** Trip duration in real milliseconds for a ship carrying `massTonnes`. */
export function tripRealMs(
model: ShipModel,
originId: string,
destinationId: string,
massTonnes: number,
now: number,
) {
const hours = travelTimeGameHours(
model,
distanceAt(originId, destinationId, now),
distanceAt(originId, destinationId),
massTonnes,
content.config,
);
@ -69,28 +62,20 @@ export function simShip(ship: Ship): ShipState {
};
}
/** Monthly samples over 16 game years from the epoch: the reach horizon (CR-012). */
const REACH_SAMPLES = (() => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const month = 30.44 * 24 * MS_PER_HOUR;
return Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * month);
})();
const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`;
const everCache = new Map<string, Set<string>>();
const legCache = new Map<string, Set<string>>();
/** Legs a model flies on at least one sampled date. */
function everInRange(model: ShipModel): Set<string> {
let legs = everCache.get(model.id);
/** Legs a model can fly (the map is fixed, decision 0.5.93). */
function legsInRange(model: ShipModel): Set<string> {
let legs = legCache.get(model.id);
if (!legs) {
legs = new Set();
for (const from of content.locations) {
for (const to of content.locations) {
if (from.id !== to.id && REACH_SAMPLES.some((t) => checkRoute(model, from, to, t).ok)) {
legs.add(legKey(from.id, to.id));
if (from.id !== to.id && checkRoute(model, from, to).ok) legs.add(legKey(from.id, to.id));
}
}
}
everCache.set(model.id, legs);
legCache.set(model.id, legs);
}
return legs;
}
@ -105,19 +90,19 @@ export interface ShipReach {
}
/**
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in range
* at `now`, legs in range on other dates, and places it can reach only by
* hopping (each leg in range on some date over 16 game years).
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in
* range from each origin, and places it can reach only by hopping. On the fixed
* map (decision 0.5.93) `otherDates` is always empty; it goes with its wording
* in Step 15.8.
*/
export function shipReach(model: ShipModel, originIds: string[], now: RealTime): ShipReach {
const t = realToGame(now, content.config);
const ever = everInRange(model);
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
const ever = legsInRange(model);
const ids = content.locations.map((l) => l.id);
const origins = new Set(originIds);
const nowLegs = originIds.map((originId) => ({
originId,
destinationIds: ids.filter(
(to) => to !== originId && checkRoute(model, location(originId), location(to), t).ok,
(to) => to !== originId && checkRoute(model, location(originId), location(to)).ok,
),
}));
const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds));

View File

@ -1,6 +1,6 @@
import type { Location } from "@deadhead/content";
import { content } from "@deadhead/content";
import { locationPosition, type GameTime, type Position } from "@deadhead/sim";
import { locationPosition, type Position } from "@deadhead/sim";
/**
* Map coordinates: the Sun at (0, 0), the edge of the map at radius 1, y down.
@ -40,12 +40,12 @@ export function toMapPoint(position: Position): MapPoint {
* distance from its parent in its true direction, since at map scale it would
* overlap it.
*/
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
export function locationMapPoint(location: Location): MapPoint {
const parentId = location.orbitParent?.locationId;
const parent = parentId ? content.location(parentId) : undefined;
if (!parent) return toMapPoint(locationPosition(location, gameTime));
const earth = locationPosition(parent, gameTime);
const moon = locationPosition(location, gameTime);
if (!parent) return toMapPoint(locationPosition(location));
const earth = locationPosition(parent);
const moon = locationPosition(location);
const dx = moon.x - earth.x;
const dy = -(moon.y - earth.y);
const length = Math.hypot(dx, dy) || 1;

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim";
import { checkRoute, effectiveStats } from "@deadhead/sim";
export interface OriginReach {
originId: string;
@ -31,17 +31,15 @@ export function playerOrigins(state: Pick<PlayerState, "bays" | "ships" | "locat
export function reachersByOrigin(
state: Pick<PlayerState, "bays" | "ships" | "locations">,
locationId: string,
now: number,
): OriginReach[] {
const target = content.location(locationId);
if (!target) return [];
const gameTime = realToGame(now, content.config);
return playerOrigins(state)
.filter((id) => id !== locationId)
.map((originId) => {
const origin = content.location(originId)!;
const reaches = (stats: Parameters<typeof checkRoute>[0]) =>
checkRoute(stats, origin, target, gameTime).ok;
checkRoute(stats, origin, target).ok;
const models = content.purchasableShipModels.filter(reaches).map((m) => m.name);
const upgraded = state.ships
.filter((ship: Ship) => {

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { dispatchProgress, locationPosition, loanerModel, realToGame } from "@deadhead/sim";
import { dispatchProgress, locationPosition, loanerModel } from "@deadhead/sim";
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
import { mapStyle } from "./style";
@ -64,14 +64,8 @@ export function anchorLocation(id: string) {
* on the line however the map is scaled.
*/
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
const from = locationMapPoint(
anchorLocation(dispatch.originId),
realToGame(dispatch.departureAt, content.config),
);
const to = locationMapPoint(
anchorLocation(dispatch.destinationId),
realToGame(dispatch.arrivalAt, content.config),
);
const from = locationMapPoint(anchorLocation(dispatch.originId));
const to = locationMapPoint(anchorLocation(dispatch.destinationId));
const progress = dispatchProgress(dispatch, now);
return {
dispatchId: dispatch.id,
@ -89,7 +83,6 @@ export function flightMarker(dispatch: Dispatch, now: number, modelId: string):
/** Everything the map draws at real time `now`, in map coordinates. */
export function buildScene(state: PlayerState, now: number): Scene {
const gameTime = realToGame(now, content.config);
const idleAt = (locationId: string) =>
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
(state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId
@ -98,11 +91,11 @@ export function buildScene(state: PlayerState, now: number): Scene {
return {
locations: content.locations.map((loc) => {
const sun = locationPosition(loc, gameTime);
const sun = locationPosition(loc);
return {
id: loc.id,
name: loc.name,
point: locationMapPoint(anchorLocation(loc.id), gameTime),
point: locationMapPoint(anchorLocation(loc.id)),
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),

View File

@ -97,12 +97,7 @@ export function explainProblems(
if (contract.massTonnes > model.cargoCapacityTonnes) {
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
}
const route = checkRoute(
model,
location(contract.originId),
location(contract.destinationId),
gameNow,
);
const route = checkRoute(model, location(contract.originId), location(contract.destinationId));
if (!route.ok) {
problems.push(
route.reason === "out-of-range"
@ -110,22 +105,14 @@ export function explainProblems(
: INELIGIBLE_REASONS["cannot-land"],
);
} else if (
wouldStrand(
model,
location(contract.originId),
location(contract.destinationId),
contract.massTonnes,
gameNow,
discoveredIdsOf(state),
content,
)
wouldStrand(model, location(contract.destinationId), discoveredIdsOf(state), content)
) {
problems.push(strandedText(contract.destinationId));
}
// Priority (CR-007): the trip must fit the delivery window.
const window = contract.priority ? contract.deliveryWindowGameHours : null;
if (window !== null) {
const trip = contractTripGameHours(model, contract, now, content);
const trip = contractTripGameHours(model, contract, content);
if (trip > window) {
problems.push(
`Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`,
@ -199,13 +186,11 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
);
const fuelCost = tripFuelCost(
model,
location(contract.originId),
location(contract.destinationId),
gameNow,
content.config,
);
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
@ -263,7 +248,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
priority:
contract.priority && contract.deliveryWindowGameHours !== null
? {
tripHours: contractTripGameHours(model, contract, now, content),
tripHours: contractTripGameHours(model, contract, content),
windowHours: contract.deliveryWindowGameHours,
}
: null,

View File

@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
import type { Dispatch, Ship } from "@deadhead/schema";
import {
dispatchProgress,
realToGame,
tripFuelCost,
validateReposition,
type RepositionFailure,
@ -210,7 +209,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
const discoveredIds = new Set(discovered);
const gameNow = realToGame(now, content.config);
const options = discovered
.filter((id) => id !== origin)
@ -240,8 +238,8 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
: check.reason === "would-strand"
? strandedText(id)
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow, content.config),
tripMs: tripRealMs(model, origin, id, 0, now),
fuelCost: tripFuelCost(model, location(origin), location(id), content.config),
tripMs: tripRealMs(model, origin, id, 0),
};
});

View File

@ -262,7 +262,7 @@ function LocationBoard({
Too heavy
</Pill>
)}
{tooSlowForAll(state, contract, now) && (
{tooSlowForAll(state, contract) && (
<Pill tone="rose" icon="clock">
Too slow
</Pill>
@ -281,7 +281,7 @@ function LocationBoard({
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
{contract.massTonnes} t
</span>{" "}
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
</span>
</div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">

View File

@ -1,6 +1,6 @@
import { content, type Location } from "@deadhead/content";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import {} from "../clock/clock";
import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
@ -21,7 +21,6 @@ const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardene
/** Every location; details, contracts and HQ leasing once discovered (Steps 9.9, 10B.9). */
export function Locations() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const lease = useGameAction("leaseBay");
return (
@ -87,7 +86,7 @@ export function Locations() {
<span className="font-display text-xs tracking-wider text-cyan-300/80">
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
</span>
</div>
</div>

View File

@ -2,7 +2,7 @@ import { content, type Location } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import {} from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format";
@ -92,12 +92,11 @@ function Panel({
/** A tapped location: details and a link to its contracts once discovered (CR-002). */
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const location = content.location(locationId)!;
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
const hasBay = state.bays.some((b) => b.locationId === locationId);
// Per origin the player has a base at (CR-012), e.g. "From Mars: Clipper · From Earth: Freighter".
const reach = discovered ? [] : reachersByOrigin(state, locationId, now);
const reach = discovered ? [] : reachersByOrigin(state, locationId);
return (
<Panel
@ -116,7 +115,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
{TYPE_LABELS[location.type]} ·{" "}
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
</p>
<p className="text-ink-400">
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·

View File

@ -1,7 +1,7 @@
import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format";
import { serverNow } from "../clock/clock";
import {} from "../clock/clock";
import { shipReach, shipStatsOf } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
@ -148,7 +148,7 @@ const names = (ids: string[]) => ids.map(locationName).join(", ");
function Reach({ model }: { model: ShipModel }) {
const state = useLoadedPlayer();
const origins = [...new Set(state.bays.map((b) => b.locationId))];
const reach = shipReach(model, origins, serverNow());
const reach = shipReach(model, origins);
const parts = [
...reach.now.map(
(leg) =>

View File

@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
import { deliveryXp, loanerModel } from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import {} from "../clock/clock";
import { formatCredits, formatDuration, locationName } from "../format";
import { goodName } from "../components/Goods";
import { distanceAt, tripRealMs } from "../game";
@ -20,7 +20,6 @@ export const INTRO_TIP = "tutorial-intro";
export function TutorialBoard() {
const api = useApi();
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const board = useQuery({
queryKey: TUTORIAL_BOARD_KEY,
queryFn: () => api.call("getTutorialBoard", {}),
@ -95,10 +94,9 @@ export function TutorialBoard() {
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
);
const xp = deliveryXp(
distanceAt(contract.originId, contract.destinationId, now),
distanceAt(contract.originId, contract.destinationId),
contract.tier,
content.config,
);

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
import { routeDistance, travelTimeGameHours } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -83,11 +83,7 @@ const state = samplePlayerState({
const tripHours = () =>
travelTimeGameHours(
content.shipModel("courier")!,
routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(NOW, content.config),
),
routeDistance(content.location("earth")!, content.location("moon")!),
moonJob.massTonnes,
content.config,
);

View File

@ -51,7 +51,7 @@ const rush: Contract = {
goodId: "mail",
shipper: "Pacific Orbital Logistics",
priority: true,
deliveryWindowGameHours: priorityWindow(earth, moon, 6, NOW, content),
deliveryWindowGameHours: priorityWindow(earth, moon, 6, content),
tier: 0,
massTonnes: 6,
payout: 1_500,
@ -62,8 +62,7 @@ const rush: Contract = {
firstContact: false,
};
const trip = (modelId: string) =>
contractTripGameHours(content.shipModel(modelId)!, rush, NOW, content);
const trip = (modelId: string) => contractTripGameHours(content.shipModel(modelId)!, rush, content);
async function openSheet() {
renderApp(

View File

@ -116,17 +116,11 @@ describe("dispatch sheet", () => {
const model = content.shipModel("freighter")!;
const earth = content.location("earth")!;
const mars = content.location("mars")!;
const gameNow = realToGame(NOW, content.config);
const tripMs =
(travelTimeGameHours(
model,
routeDistance(earth, mars, gameNow),
marsJob.massTonnes,
content.config,
) *
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
MS_PER_HOUR) /
content.config.timeCompression;
const fuel = tripFuelCost(model, earth, mars, gameNow, content.config);
const fuel = tripFuelCost(model, earth, mars, content.config);
const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config);
expect(within(freighter).getByRole("radio")).toBeEnabled();

View File

@ -94,11 +94,7 @@ describe("effective stats in the client (Step 10I.7)", () => {
});
it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => {
const distance = routeDistance(
content.location("earth")!,
content.location("mars")!,
realToGame(NOW, content.config),
);
const distance = routeDistance(content.location("earth")!, content.location("mars")!);
// Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map.
expect(distance).toBeGreaterThan(145);
expect(distance).toBeLessThan(215);

View File

@ -1,5 +1,5 @@
import { content } from "@deadhead/content";
import { checkRoute, realToGame } from "@deadhead/sim";
import { checkRoute } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -53,11 +53,8 @@ describe("whole map from the start", () => {
const panel = screen.getByRole("dialog", { name: "Mars" });
expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock.");
const gameTime = realToGame(NOW, content.config);
const expected = content.purchasableShipModels
.filter(
(m) => checkRoute(m, content.location("earth")!, content.location("mars")!, gameTime).ok,
)
.filter((m) => checkRoute(m, content.location("earth")!, content.location("mars")!).ok)
.map((m) => m.name);
expect(expected.length).toBeGreaterThan(0);
expect(expected).not.toContain("Courier");

View File

@ -28,13 +28,11 @@ describe("radial scale", () => {
describe("map fits a phone", () => {
// A 375 px wide phone, minus header and tab bar.
const viewport = { width: 375, height: 690 };
const start = Date.parse("2100-01-01T00:00:00Z");
it("shows all five locations on screen at default zoom, across 12 years", () => {
for (let month = 0; month < 12 * 12; month++) {
const gameTime = start + month * 30 * 86_400_000;
for (const location of content.locations) {
const p = toScreen(locationMapPoint(location, gameTime), viewport, DEFAULT_CAMERA);
const p = toScreen(locationMapPoint(location), viewport, DEFAULT_CAMERA);
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(viewport.width);
expect(p.y).toBeGreaterThanOrEqual(0);
@ -44,8 +42,8 @@ describe("map fits a phone", () => {
});
it("draws the Moon apart from Earth", () => {
const earth = locationMapPoint(content.location("earth")!, start);
const moon = locationMapPoint(content.location("moon")!, start);
const earth = locationMapPoint(content.location("earth")!);
const moon = locationMapPoint(content.location("moon")!);
const a = toScreen(earth, viewport, DEFAULT_CAMERA);
const b = toScreen(moon, viewport, DEFAULT_CAMERA);
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5);

View File

@ -16,7 +16,7 @@ const [mars, ceres] = [content.location("mars")!, content.location("ceres-statio
const NOW = (() => {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, mars, ceres, t).ok) return gameToReal(t, content.config);
if (checkRoute(clipper, mars, ceres).ok) return gameToReal(t, content.config);
}
throw new Error("no such date");
})();

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { pilotShare, realToGame, tripFuelCost } from "@deadhead/sim";
import { pilotShare, tripFuelCost } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -110,7 +110,6 @@ describe("pilot share in the client (Step 10J.6)", () => {
content.shipModel("courier")!,
content.location("earth")!,
content.location("moon")!,
realToGame(NOW, content.config),
content.config,
);
const share = pilotShare(job.payout, fuel, content.config);

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -17,10 +17,9 @@ const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform
// The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from
// Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered.
const gameNow = realToGame(NOW, content.config);
if (
!checkRoute(freighter, moon, jupiter, gameNow).ok ||
!wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content)
!checkRoute(freighter, moon, jupiter).ok ||
!wouldStrand(freighter, jupiter, discovered, content)
) {
throw new Error("the scenario no longer strands");
}

View File

@ -1,12 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema";
import {
deliveryXp,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
} from "@deadhead/sim";
import { deliveryXp, MS_PER_HOUR, routeDistance, travelTimeGameHours } from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -23,7 +17,6 @@ function expected(contract: Contract) {
const distance = routeDistance(
content.location(contract.originId)!,
content.location(contract.destinationId)!,
realToGame(NOW, content.config),
);
const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
return {

View File

@ -371,7 +371,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as "CR-014 … Phase 14"). New decisions recorded as plan §0.5.93–0.5.99. User decision: a leg's fuel follows the economic-distance curve, like pay.
- [x] **Step 15.1 — Content: fixed positions.** Locations lose `body` and gain `position` (`{ radiusMillionKm, angleDegrees }`): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (`offsetPosition`), recorded as `orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }`. GameConfig's epoch keeps only a displayed start date (`gameStart`, was `ephemerisDate`; same value). `astronomy-engine` is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's `locationPosition` already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (`bodyPosition` is gone; the Moon is placed from `orbitParent`). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: `packages/content/test/fixed-map.test.ts` (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no `astronomy-engine` in the lockfile). Date-sampled tests became yes/no: `reach-table.test.ts` is now CR-015 §4, one test per cell; `courier-mars`, `eligibility`, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8).
- [ ] Step 15.2 — Sim: static positions and distances
- [x] **Step 15.2 — Sim: static positions and distances.** No time arguments left where a time only fed a position: `locationPosition(location)`, `routeDistance(from, to)`, `checkRoute(ship, from, to)`, `tripFuelCost(model, origin, destination, config)`, `contractTripGameHours(model, contract, content)`, `priorityWindow(origin, destination, mass, content)`, and `wouldStrand(model, destination, discoveredIds, content)` (it had taken cargo mass and departure only to find where planets would be on arrival). `dispatchPosition` runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's `noStrandMarginGameHours` is gone (nothing moves in 48 game hours now). Web: `distanceAt`, `tripRealMs`, `locationMapPoint`, `reachersByOrigin`, `tooSlowForAll` and `shipReach` lose their time argument; `shipReach` checks each leg once instead of sampling 16 game years (its `otherDates` is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: `distance.test.ts` per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), `positions.test.ts` ("never moves"), new `static-routes.test.ts` (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); `no-strand.test.ts` now checks that no base ship is ever blocked on a leg it can fly; `range-from` and `upgrade-caps` lose their date loops.
- [ ] Step 15.3 — Content: fuel
- [ ] Step 15.4 — Sim: tank, burn, and refuel
- [ ] Step 15.5 — Sim: stranding and emergency fuel

View File

@ -30,8 +30,6 @@ export const gameConfig: GameConfig = {
savedDispatchRetentionRealHours: 168,
// Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84).
economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 },
// A ship must be able to fly out of its destination two game days after arriving (CR-012).
noStrandMarginGameHours: 48,
// Ship upgrades (CR-009): level 1, 2, 3.
upgradeLevels: [
{ costPercentOfPrice: 10, shopRealMinutes: 10 },

View File

@ -246,8 +246,6 @@ export const gameConfigSchema = z
referenceMillionKm: z.number().positive(),
exponent: z.number().positive().max(1),
}),
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
noStrandMarginGameHours: z.number().nonnegative(),
/**
* Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and
* real minutes in the shop (CR-009).

View File

@ -80,7 +80,6 @@ const validGameConfig = {
contractBoardSize: 8,
tutorialBoardSize: 50,
contractBoardPeriodRealMinutes: 60,
noStrandMarginGameHours: 48,
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
restGameHours: 60,

View File

@ -213,7 +213,7 @@ describe("Priority contracts (CR-007)", () => {
async function priorityJob(o: Owner, id: string, slackHours: number) {
const { clock } = testApp();
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
const trip = contractTripGameHours(courier, base, clock.now, content);
const trip = contractTripGameHours(courier, base, content);
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
await putBoard(testApp().app, [contract], clock.now);
return contract;

View File

@ -1,7 +1,7 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { TEST_NOW, useTestApp, call, getState, edit, setCredits, signUpOwner } from "../support";
@ -27,7 +27,7 @@ describe("Lighter and Clipper (Step 10L.6)", () => {
}
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
const { app, clock } = testApp();
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 100_000);
// A Freighter with maxed tanks at the Moon, with Jupiter discovered but not
@ -54,10 +54,9 @@ describe("Lighter and Clipper (Step 10L.6)", () => {
content.location("moon")!,
content.location("jupiter-cloud-platform")!,
];
const now = realToGame(clock.now, content.config);
expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true);
expect(checkRoute(freighter, moon, jupiter).ok).toBe(true);
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
expect(wouldStrand(freighter, moon, jupiter, 0, now, discovered, content)).toBe(true);
expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {

View File

@ -9,7 +9,6 @@ import {
import {
effectiveStats,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
upgradeCost,
@ -93,11 +92,7 @@ describe("starting an upgrade (Step 10I.5)", () => {
const flown = await dispatch();
expect(flown.statusCode).toBe(200);
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
const distance = routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(until, content.config),
);
const distance = routeDistance(content.location("earth")!, content.location("moon")!);
const hours = travelTimeGameHours(
effectiveStats(freighterModel, { engines: 1 }, content),
distance,

View File

@ -27,7 +27,6 @@ import {
nextUpgradeLevel,
pilotLevel,
pilotShare,
realToGame,
resolveDispatch,
restEndsAt,
skillPointsAvailable,
@ -244,17 +243,10 @@ export function simulate(options: SimulationOptions): SimulationResult {
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
const departure = realToGame(now, config);
const fuel = tripFuelCost(
stats,
loc(contract.originId),
loc(contract.destinationId),
departure,
config,
);
const fuel = tripFuelCost(stats, loc(contract.originId), loc(contract.destinationId), config);
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
const tripMs =
(contractTripGameHours(stats, contract, now, content) * MS_PER_HOUR) / config.timeCompression;
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE);
}
@ -594,9 +586,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
const target = deadheads
.map((to) => {
const best = board(to)
.filter(
(c) => checkRoute(stats, loc(to), loc(c.destinationId), realToGame(now, config)).ok,
)
.filter((c) => checkRoute(stats, loc(to), loc(c.destinationId)).ok)
.map((c) => c.payout)
.sort((a, b) => b - a)[0];
return { to, best: best ?? 0 };
@ -643,13 +633,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
discovered.has(ship.locationId) &&
board(ship.locationId).some(
(c) =>
tripFuelCost(
model(ship.modelId),
loc(c.originId),
loc(c.destinationId),
realToGame(now, config),
config,
) <= credits,
tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
credits,
)
);
});

View File

@ -8,7 +8,7 @@ import type {
Skill,
Upgrade,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
import { MS_PER_MINUTE, type RealTime } from "./clock";
import { contractPayout, type Contract } from "./contract";
import { routeDistance } from "./distance";
import { checkRoute } from "./eligibility";
@ -125,7 +125,6 @@ function fitJob(
offeredAt: RealTime,
content: SimContent,
) {
const gameTime = realToGame(offeredAt, content.config);
const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min);
if (able.length === 0) return null;
const start = rng.int(0, able.length - 1);
@ -133,8 +132,8 @@ function fitJob(
const { model, skills } = able[(start + k) % able.length]!;
const reachable = discovered.filter(
(d) =>
checkRoute(model, origin, d, gameTime).ok &&
requiredLongHaulLevel(routeDistance(origin, d, gameTime), content.config) <=
checkRoute(model, origin, d).ok &&
requiredLongHaulLevel(routeDistance(origin, d), content.config) <=
skillLevel(skills, LONG_HAUL_SKILL_ID),
);
if (reachable.length === 0) continue;
@ -266,13 +265,12 @@ export function priorityWindow(
origin: Location,
destination: Location,
massTonnes: number,
offeredAt: RealTime,
content: SimContent,
): number {
const { config } = content;
const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId);
if (!reference) throw new Error(`Unknown ship model ${config.priorityReferenceShipModelId}`);
const distance = routeDistance(origin, destination, realToGame(offeredAt, config));
const distance = routeDistance(origin, destination);
return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor;
}
@ -294,19 +292,9 @@ function rollPriority(
): { priority: boolean; deliveryWindowGameHours: number | null } {
const none = { priority: false, deliveryWindowGameHours: null };
if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none;
const window = priorityWindow(
args.origin,
args.destination,
args.massTonnes,
args.offeredAt,
content,
);
const window = priorityWindow(args.origin, args.destination, args.massTonnes, content);
if (args.model) {
const distance = routeDistance(
args.origin,
args.destination,
realToGame(args.offeredAt, content.config),
);
const distance = routeDistance(args.origin, args.destination);
const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config);
if (trip > window) return none;
}
@ -358,7 +346,7 @@ export function buildContract(
const { config } = content;
const cargoType = cargoClassOf(args.good, content);
const offeredAt = args.periodIndex * boardPeriodMs(config);
const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config));
const distance = routeDistance(args.origin, args.destination);
return {
id: args.id,
originId: args.origin.id,

View File

@ -1,6 +1,6 @@
import type { GameConfig, Location, ShipModel } from "@deadhead/content";
import type { SimContent } from "./board";
import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock";
import { MS_PER_HOUR, type RealTime } from "./clock";
import type { Contract } from "./contract";
import { routeDistance } from "./distance";
import { isResting } from "./duty";
@ -88,56 +88,47 @@ export function findLocation(id: string, content: SimContent): Location {
return location;
}
/** Fuel charged for a trip, in whole credits, using the distance at departure. */
/** Fuel charged for a trip, in whole credits. */
export function tripFuelCost(
model: ShipModel,
origin: Location,
destination: Location,
departure: GameTime,
config: GameConfig,
): number {
return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure), config));
return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config));
}
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
/** A ship's trip time for a contract, departing now: cruise, overhead and handling (game hours). */
/** A ship's trip time for a contract: cruise, overhead and handling (game hours). */
export function contractTripGameHours(
model: ShipModel,
contract: Contract,
now: RealTime,
content: SimContent,
): number {
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const distance = routeDistance(origin, destination, realToGame(now, content.config));
const distance = routeDistance(origin, destination);
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
}
/**
* The no-strand rule (decision 0.5.79): true when, at arrival plus the configured
* margin, the ship could not fly from the destination to any other discovered
* location. `discoveredIds` absent means every location counts.
* The no-strand rule (decision 0.5.79): true when the ship could not fly from
* the destination to any other discovered location. `discoveredIds` absent
* means every location counts. The map is fixed (0.5.93), so arrival time
* doesn't matter.
*/
export function wouldStrand(
/** Effective stats, or a bare model (handling falls back to the game-wide base). */
model: ShipModel,
origin: Location,
destination: Location,
massTonnes: number,
departure: GameTime,
discoveredIds: ReadonlySet<string> | undefined,
content: SimContent,
): boolean {
const { config } = content;
const distance = routeDistance(origin, destination, departure);
const tripHours = travelTimeGameHours(model, distance, massTonnes, config);
const at = departure + (tripHours + config.noStrandMarginGameHours) * MS_PER_HOUR;
return !content.locations.some(
(next) =>
next.id !== destination.id &&
(discoveredIds?.has(next.id) ?? true) &&
checkRoute(model, destination, next, at).ok,
checkRoute(model, destination, next).ok,
);
}
@ -147,7 +138,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const model = shipStats(ship, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const departure = realToGame(now, content.config);
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
@ -161,19 +151,9 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
}
if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity");
const route = checkRoute(model, origin, destination, departure);
const route = checkRoute(model, origin, destination);
if (!route.ok) return fail(route.reason);
if (
wouldStrand(
model,
origin,
destination,
contract.massTonnes,
departure,
proposal.discoveredIds,
content,
)
) {
if (wouldStrand(model, destination, proposal.discoveredIds, content)) {
return fail("would-strand");
}
@ -181,7 +161,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
if (
contract.priority &&
contract.deliveryWindowGameHours !== null &&
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours
contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours
) {
return fail("too-slow");
}
@ -192,7 +172,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
if (now >= contract.expiresAt) return fail("contract-expired");
if (!tutorial) {
if (credits < tripFuelCost(model, origin, destination, departure, content.config))
if (credits < tripFuelCost(model, origin, destination, content.config))
return fail("insufficient-credits");
}
return { ok: true };
@ -247,8 +227,7 @@ function buildRecord(
const model = shipStats(ship, content);
const origin = findLocation(args.originId, content);
const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config);
const distance = routeDistance(origin, destination, departure);
const distance = routeDistance(origin, destination);
const mass = contract?.massTonnes ?? 0;
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
@ -263,7 +242,7 @@ function buildRecord(
departureAt: now,
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
distanceMillionKm: distance,
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure, content.config),
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, content.config),
expectedPayout: contract?.payout ?? 0,
seed: deriveSeed(worldSeed, `dispatch:${id}`),
};
@ -329,14 +308,13 @@ export function validateReposition(
const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, content);
const departure = realToGame(now, content.config);
const route = checkRoute(model, origin, destination, departure);
const route = checkRoute(model, origin, destination);
if (!route.ok) return failWith(route.reason);
if (wouldStrand(model, origin, destination, 0, departure, discoveredIds, content)) {
if (wouldStrand(model, destination, discoveredIds, content)) {
return failWith("would-strand");
}
if (credits < tripFuelCost(model, origin, destination, departure, content.config)) {
if (credits < tripFuelCost(model, origin, destination, content.config)) {
return failWith("insufficient-credits");
}
return { ok: true };

View File

@ -1,10 +1,9 @@
import type { Location } from "@deadhead/content";
import type { GameTime } from "./clock";
import { locationPosition } from "./positions";
/** Straight-line distance between two locations at a game time, in million km. */
export function routeDistance(from: Location, to: Location, gameTime: GameTime): number {
const a = locationPosition(from, gameTime);
const b = locationPosition(to, gameTime);
/** Straight-line distance between two locations, in million km; fixed (decision 0.5.93). */
export function routeDistance(from: Location, to: Location): number {
const a = locationPosition(from);
const b = locationPosition(to);
return Math.hypot(b.x - a.x, b.y - a.y);
}

View File

@ -1,5 +1,4 @@
import type { LandingClass, Location, ShipModel } from "@deadhead/content";
import type { GameTime } from "./clock";
import { routeDistance } from "./distance";
const LANDING_RANK: Record<LandingClass, number> = { vacuum: 0, atmospheric: 1, hardened: 2 };
@ -20,17 +19,12 @@ export function rangeFrom(ship: ShipModel, from: Location): number {
}
/**
* Whether a ship can fly from one location to another at a game time, or why
* not. Range is per leg, from the origin, at departure (decision 0.5.77).
* Whether a ship can fly from one location to another, or why not. Range is
* per leg, from the origin (decision 0.5.77); the map is fixed (0.5.93).
*/
export function checkRoute(
ship: ShipModel,
from: Location,
to: Location,
gameTime: GameTime,
): RouteCheck {
export function checkRoute(ship: ShipModel, from: Location, to: Location): RouteCheck {
if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" };
if (routeDistance(from, to, gameTime) > rangeFrom(ship, from)) {
if (routeDistance(from, to) > rangeFrom(ship, from)) {
return { ok: false, reason: "out-of-range" };
}
return { ok: true };

View File

@ -1,5 +1,5 @@
import type { SimContent } from "./board";
import { type RealTime, realToGame } from "./clock";
import type { RealTime } from "./clock";
import { type DispatchRecord, findLocation } from "./dispatch";
import { locationPosition, type Position } from "./positions";
@ -10,24 +10,14 @@ export function dispatchProgress(record: DispatchRecord, now: RealTime): number
return Math.min(1, Math.max(0, (now - record.departureAt) / duration));
}
/**
* Ship position at a real time: a straight line from the origin's position at
* departure to the destination's position at arrival.
*/
/** Ship position at a real time: along the straight line from origin to destination. */
export function dispatchPosition(
record: DispatchRecord,
now: RealTime,
content: SimContent,
): Position {
const { config } = content;
const start = locationPosition(
findLocation(record.originId, content),
realToGame(record.departureAt, config),
);
const end = locationPosition(
findLocation(record.destinationId, content),
realToGame(record.arrivalAt, config),
);
const start = locationPosition(findLocation(record.originId, content));
const end = locationPosition(findLocation(record.destinationId, content));
const t = dispatchProgress(record, now);
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
}

View File

@ -1,5 +1,4 @@
import type { FixedPosition, Location } from "@deadhead/content";
import type { GameTime } from "./clock";
/** A heliocentric point in the plane of the map, in million km. */
export interface Position {
@ -16,10 +15,7 @@ export function fixedPoint(position: FixedPosition): Position {
};
}
/**
* A location's position in million km from the Sun. The map is fixed
* (decision 0.5.93); the game time is ignored and goes away in Step 15.2.
*/
export function locationPosition(location: Location, _gameTime?: GameTime): Position {
/** A location's position in million km from the Sun; it never moves (decision 0.5.93). */
export function locationPosition(location: Location): Position {
return fixedPoint(location.position);
}

View File

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from "vitest";
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
import { content } from "@deadhead/content";
import { MS_PER_HOUR, realToGame, tripFuelCost } from "../src/index";
import { MS_PER_HOUR, tripFuelCost } from "../src/index";
/**
* Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on
@ -75,8 +75,7 @@ describe("balance simulation (Step 12.1)", () => {
// Step 12.2: the tutorial share and starting credits as actually earned.
const courier = content.shipModel("courier")!;
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const fuel = tripFuelCost(courier, earth, moon, start, content.config);
const fuel = tripFuelCost(courier, earth, moon, content.config);
for (const result of lighterFirst) {
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
expect(result.tutorialShareEarned).toBeGreaterThan(0);

View File

@ -6,7 +6,6 @@ import {
checkRoute,
contractTripGameHours,
generateContractBoard,
realToGame,
type BoardInput,
type Contract,
type FleetMember,
@ -21,16 +20,14 @@ const bands = config.contractMassBands;
/** Whether this crew can fly the contract (the sim's own route and pilot rules). */
function fits(contract: Contract, member: FleetMember): boolean {
const model = content.shipModel(member.modelId)!;
const gameTime = realToGame(contract.offeredAt, config);
return (
!contract.firstContact &&
contract.massTonnes <= model.cargoCapacityTonnes &&
checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok &&
checkRoute(model, earth, content.location(contract.destinationId)!).ok &&
checkPilotEligibility(member.skills, contract).ok &&
// Priority (CR-007): the ship must make the delivery window.
(!contract.priority ||
contractTripGameHours(model, contract, contract.offeredAt, content) <=
contract.deliveryWindowGameHours!)
contractTripGameHours(model, contract, content) <= contract.deliveryWindowGameHours!)
);
}

View File

@ -107,7 +107,7 @@ describe("goods in board generation (CR-007)", () => {
priority++;
expect(good.priorityEligible).toBe(true);
expect(c.deliveryWindowGameHours).toBeCloseTo(
priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, c.offeredAt, content),
priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, content),
);
}
}

View File

@ -7,7 +7,6 @@ const tanks = content.upgrade("fuel-tanks")!;
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!;
const now = Date.parse("2100-01-01T00:00:00Z");
/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */
const withTanks = (level: number): ShipModel => ({
@ -19,13 +18,13 @@ const withTanks = (level: number): ShipModel => ({
// Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map.
describe("Courier to Mars (decision 0.5.68)", () => {
it("reaches Mars from Earth with maxed fuel tanks", () => {
expect(checkRoute(withTanks(3), earth, mars, now)).toEqual({ ok: true });
expect(checkRoute(withTanks(3), earth, mars)).toEqual({ ok: true });
});
it("can't reach Mars from Earth or the Moon with fuel tanks at level 2 or less", () => {
for (const level of [0, 1, 2]) {
expect(checkRoute(withTanks(level), earth, mars, now).ok, `level ${level}`).toBe(false);
expect(checkRoute(withTanks(level), moon, mars, now).ok, `level ${level}`).toBe(false);
expect(checkRoute(withTanks(level), earth, mars).ok, `level ${level}`).toBe(false);
expect(checkRoute(withTanks(level), moon, mars).ok, `level ${level}`).toBe(false);
}
});
});

View File

@ -1,32 +1,29 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { routeDistance } from "../src/index";
import { locationPosition, routeDistance } from "../src/index";
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!;
const start = Date.parse("2100-01-01T00:00:00Z");
const MS_PER_DAY = 86_400_000;
/** Game times sampled every ~30 days across two years. */
const monthly = Array.from({ length: 25 }, (_, i) => start + i * 30 * MS_PER_DAY);
// Step 15.2 (decision 0.5.93): positions and distances take no time; they never change.
describe("route distance", () => {
it("keeps Earth→Moon under 1 million km at any time", () => {
for (const t of monthly) {
expect(routeDistance(earth, moon, t)).toBeLessThan(1);
}
});
it("gives the same distance at every time (decision 0.5.93)", () => {
const first = routeDistance(earth, mars, monthly[0]!);
for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first);
it("keeps Earth→Moon under 1 million km", () => {
expect(routeDistance(earth, moon)).toBeLessThan(1);
});
it("is symmetric", () => {
for (const a of content.locations) {
for (const b of content.locations) {
expect(routeDistance(a, b, start)).toBe(routeDistance(b, a, start));
expect(routeDistance(a, b)).toBe(routeDistance(b, a));
}
}
});
it("returns the same value for the same inputs, every time", () => {
for (const a of content.locations) {
expect(locationPosition(a)).toEqual(locationPosition(a));
for (const b of content.locations) {
expect(routeDistance(a, b)).toBe(routeDistance(a, b));
}
}
});

View File

@ -2,7 +2,6 @@ import { content, SHIP_STATS } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
effectiveStats,
realToGame,
routeDistance,
shipStats,
travelTimeGameHours,
@ -86,11 +85,7 @@ describe("effective stats (Step 10I.2)", () => {
});
it("lets an engines upgrade make a Priority window that was too short", () => {
const distance = routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(now, content.config),
);
const distance = routeDistance(content.location("earth")!, content.location("moon")!);
const hours = (stats: typeof courier) =>
travelTimeGameHours(stats, distance, contract.massTonnes, content.config);
const slow = hours(effectiveStats(courier, {}, content));

View File

@ -15,38 +15,37 @@ const bulkHauler = content.shipModel("bulk-hauler")!;
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
// The map is fixed (decision 0.5.93), so any time gives the same answer.
const now = Date.parse("2100-01-01T00:00:00Z");
const outOfRange = { ok: false, reason: "out-of-range" };
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
describe("route eligibility", () => {
it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, now)).toEqual({ ok: true });
expect(checkRoute(courier, earth, moon)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth)).toEqual({ ok: true });
});
it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => {
for (const to of [mars, ceres, jupiter]) {
expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange);
expect(checkRoute(courier, earth, to)).toEqual(outOfRange);
}
});
it("rejects a vacuum-only ship at Earth because it cannot land", () => {
expect(checkRoute(vacuumShip, moon, earth, now)).toEqual({
expect(checkRoute(vacuumShip, moon, earth)).toEqual({
ok: false,
reason: "cannot-land",
});
expect(checkRoute(vacuumShip, earth, moon, now)).toEqual({ ok: true });
expect(checkRoute(vacuumShip, earth, moon)).toEqual({ ok: true });
});
it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => {
expect(checkRoute(freighter, earth, mars, now)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, ceres, now)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, mars)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, ceres)).toEqual({ ok: true });
});
it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange);
expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange);
expect(checkRoute(bulkHauler, earth, jupiter)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter)).toEqual(outOfRange);
expect(checkRoute(courier, earth, jupiter)).toEqual(outOfRange);
});
});

View File

@ -5,7 +5,6 @@ import {
dispatchPosition,
dispatchProgress,
locationPosition,
realToGame,
} from "../src/index";
const record = createRepositionRecord(
@ -42,14 +41,8 @@ describe("in-flight progress", () => {
});
describe("in-flight position", () => {
const origin = locationPosition(
content.location("earth")!,
realToGame(record.departureAt, content.config),
);
const destination = locationPosition(
content.location("moon")!,
realToGame(record.arrivalAt, content.config),
);
const origin = locationPosition(content.location("earth")!);
const destination = locationPosition(content.location("moon")!);
it("starts at the origin's position at departure", () => {
expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin);

View File

@ -28,8 +28,7 @@ describe("economic distance (decision 0.5.84)", () => {
describe("fuel cost", () => {
it("costs more Earth→Moon than Moon→Earth for the same ship", () => {
const t = Date.parse("2100-01-01T00:00:00Z");
const distance = routeDistance(earth, moon, t);
const distance = routeDistance(earth, moon);
expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan(
fuelCost(courier, moon, distance, config),
);

View File

@ -1,12 +1,8 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { content, type Location } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
checkRoute,
effectiveStats,
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
realToGame,
validateDispatch,
validateReposition,
wouldStrand,
@ -31,7 +27,7 @@ const pilot: PilotState = {
/**
* 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.
* Moon's position, 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" };
@ -95,38 +91,16 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
).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;
it("never blocks a base ship on a leg it can fly between the five locations (fixed map)", () => {
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,
if (from === to || !checkRoute(model, loc(from), loc(to)).ok) continue;
expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe(
false,
);
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);
}
}
}
}

View File

@ -1,12 +1,6 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
contractPayout,
generateContractBoard,
realToGame,
routeDistance,
type Contract,
} from "../src/index";
import { contractPayout, generateContractBoard, routeDistance, type Contract } from "../src/index";
const { config } = content;
const v = config.contractPayVariance;
@ -24,7 +18,6 @@ function basePayout(c: Contract): number {
distanceMillionKm: routeDistance(
content.location(c.originId)!,
content.location(c.destinationId)!,
realToGame(c.offeredAt, config),
),
firstContact: c.firstContact,
priority: c.priority,

View File

@ -2,9 +2,8 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { locationPosition } from "../src/index";
const when = Date.parse("2100-03-01T00:00:00Z");
const fromSun = (id: string) => {
const { x, y } = locationPosition(content.location(id)!, when);
const { x, y } = locationPosition(content.location(id)!);
return Math.hypot(x, y);
};
@ -29,14 +28,14 @@ describe("location positions", () => {
});
it("keeps the Moon within 1 million km of Earth", () => {
const earth = locationPosition(content.location("earth")!, when);
const moon = locationPosition(content.location("moon")!, when);
const earth = locationPosition(content.location("earth")!);
const moon = locationPosition(content.location("moon")!);
expect(Math.hypot(moon.x - earth.x, moon.y - earth.y)).toBeLessThan(1);
});
it("returns identical values for the same time", () => {
it("never moves (decision 0.5.93)", () => {
for (const location of content.locations) {
expect(locationPosition(location, when)).toEqual(locationPosition(location, when));
expect(locationPosition(location)).toEqual(locationPosition(location));
}
});
});

View File

@ -28,9 +28,7 @@ function mailContract(priority: boolean): Contract {
periodIndex,
shipper: earth.shippers[0]!,
priority,
deliveryWindowGameHours: priority
? priorityWindow(earth, moon, massTonnes, offeredAt, content)
: null,
deliveryWindowGameHours: priority ? priorityWindow(earth, moon, massTonnes, content) : null,
},
content,
);
@ -69,7 +67,7 @@ describe("Priority validation (CR-007)", () => {
it("measures the trip the same way the window was set", () => {
const contract = mailContract(true);
const freighter = content.shipModel("freighter")!;
expect(contractTripGameHours(freighter, contract, offeredAt, content)).toBeCloseTo(
expect(contractTripGameHours(freighter, contract, content)).toBeCloseTo(
contract.deliveryWindowGameHours!,
);
});

View File

@ -1,13 +1,10 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, rangeFrom, realToGame, routeDistance } from "../src/index";
import { checkRoute, rangeFrom, routeDistance } from "../src/index";
const loc = (id: string) => content.location(id)!;
const courier = content.shipModel("courier")!;
const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range });
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const MONTH = 30.44 * 24 * 3_600_000;
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
describe("range from the origin (Step 10L.2)", () => {
it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => {
@ -17,28 +14,25 @@ describe("range from the origin (Step 10L.2)", () => {
});
it("passes a leg from the Moon that fails from Earth when it is between 1× and 1.3× range", () => {
const t = samples[0]!;
const d = routeDistance(loc("moon"), loc("mars"), t);
const d = routeDistance(loc("moon"), loc("mars"));
const ship = withRange(d / 1.2);
expect(routeDistance(loc("earth"), loc("mars"), t)).toBeGreaterThan(ship.fuelRangeMillionKm);
expect(checkRoute(ship, loc("earth"), loc("mars"), t)).toEqual({
expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(ship.fuelRangeMillionKm);
expect(checkRoute(ship, loc("earth"), loc("mars"))).toEqual({
ok: false,
reason: "out-of-range",
});
expect(checkRoute(ship, loc("moon"), loc("mars"), t)).toEqual({ ok: true });
expect(checkRoute(ship, loc("moon"), loc("mars"))).toEqual({ ok: true });
});
it("matches the old check exactly when every factor is 1", () => {
const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 });
const ids = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"];
for (const range of [5, 198, 450, 1250]) {
for (const range of [5, 250, 460, 1250]) {
for (const from of ids) {
for (const to of ids) {
if (from === to) continue;
for (const t of samples) {
const old = routeDistance(loc(from), loc(to), t) <= range;
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), t).ok).toBe(old);
}
const old = routeDistance(loc(from), loc(to)) <= range;
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to))).ok).toBe(old);
}
}
}

View File

@ -6,7 +6,6 @@ import { checkRoute } from "../src/index";
* CR-015 §4: reach for base ships on the fixed map, a plain yes or no per leg
* (decision 0.5.81 as amended). Replaces CR-012's date-sampled table.
*/
const now = Date.parse("2100-01-01T00:00:00Z");
const NAMES: Record<string, string> = {
earth: "Earth",
moon: "Moon",
@ -18,7 +17,7 @@ const ids = Object.keys(NAMES);
function reaches(modelId: string, fromId: string, toId: string): boolean {
const ship = content.shipModel(modelId)!;
return checkRoute(ship, content.location(fromId)!, content.location(toId)!, now).ok;
return checkRoute(ship, content.location(fromId)!, content.location(toId)!).ok;
}
/** [model, from, to] for every leg the table says yes or no to. */

View File

@ -0,0 +1,68 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { buildContract } from "../src/board";
import { createDispatchRecord, type Contract } from "../src/index";
const earth = content.location("earth")!;
const ceres = content.location("ceres-station")!;
const good = content.goods.find(
(g) => g.producedAt.includes("earth") && g.demandedAt.includes("ceres-station"),
)!;
const DAY = 24 * 3_600_000;
/** The same Earth → Ceres job, offered in a given board period. */
function offered(periodIndex: number): Contract {
return buildContract(
{
id: `c${periodIndex}`,
origin: earth,
destination: ceres,
good,
tier: 0,
massTonnes: 30,
firstContact: false,
periodIndex,
shipper: "Test Freight",
priority: false,
deliveryWindowGameHours: null,
},
content,
);
}
function flown(contract: Contract, now: number) {
const ship = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false };
const pilot = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
return createDispatchRecord(
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 1 },
content,
);
}
// Step 15.2 (decision 0.5.93): nothing about a route depends on when.
describe("static routes", () => {
it("pays the same for a route whenever it is offered", () => {
const payouts = [0, 1, 1_000, 52_000].map((period) => offered(period).payout);
expect(new Set(payouts).size).toBe(1);
});
it("takes the same time, distance and fuel whenever it is dispatched", () => {
const contract = offered(0);
const trips = [0, 37 * DAY, 400 * DAY, 3_000 * DAY].map((t) => {
const record = flown(contract, Date.parse("2100-01-01T00:00:00Z") + t);
return {
duration: record.arrivalAt - record.departureAt,
distance: record.distanceMillionKm,
fuel: record.fuelCost,
};
});
for (const trip of trips) expect(trip).toEqual(trips[0]);
});
});

View File

@ -6,12 +6,7 @@ const config = content.config;
const courier = content.shipModel("courier")!;
const freighter = content.shipModel("freighter")!;
const loaner = content.shipModel("company-courier")!;
const earthMoon = () =>
routeDistance(
content.location("earth")!,
content.location("moon")!,
Date.parse("2100-01-01T00:00:00Z"),
);
const earthMoon = () => routeDistance(content.location("earth")!, content.location("moon")!);
describe("travel time", () => {
it("matches a hand calculation, including handling time", () => {

View File

@ -7,7 +7,6 @@ import {
generateTutorialBoard,
loanerModel,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type Contract,
@ -25,7 +24,6 @@ function distanceOf(contract: Contract): number {
return routeDistance(
content.location(contract.originId)!,
content.location(contract.destinationId)!,
realToGame(contract.offeredAt, config),
);
}

View File

@ -1,6 +1,6 @@
import { content, upgradeMaxLevel, upgradePercentPerLevel, upgrades } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, effectiveStats, realToGame, routeDistance } from "../src/index";
import { checkRoute, effectiveStats } from "../src/index";
/** Every upgrade at its highest level for the model (decision 0.5.63). */
const maxedStats = (modelId: string) =>
@ -10,9 +10,6 @@ const maxedStats = (modelId: string) =>
content,
);
const MONTH = 30.44 * 24 * 3_600_000;
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!;
@ -22,7 +19,7 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => {
it("a maxed Lighter never reaches Mars from Earth or the Moon", () => {
const lighter = maxedStats("lighter");
for (const from of [earth, moon]) {
for (const t of samples) expect(checkRoute(lighter, from, mars, t).ok).toBe(false);
expect(checkRoute(lighter, from, mars).ok).toBe(false);
}
});
@ -38,12 +35,9 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => {
);
});
it("only Bulk Haulers reach Jupiter from Earth at its farthest, even maxed", () => {
const farthest = samples.reduce((a, b) =>
routeDistance(earth, jupiter, b) > routeDistance(earth, jupiter, a) ? b : a,
);
it("only Bulk Haulers reach Jupiter from Earth, even maxed", () => {
for (const ship of content.shipModels) {
const reaches = checkRoute(maxedStats(ship.id), earth, jupiter, farthest).ok;
const reaches = checkRoute(maxedStats(ship.id), earth, jupiter).ok;
expect(reaches, ship.id).toBe(ship.id === "bulk-hauler");
}
});