Step 15.2: sim: static positions and distances
CI / check (push) Successful in 5m44s
Details
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:
parent
d6bca5b1a2
commit
0a1ca1d3ea
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
|
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";
|
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 [earth, mars] = [content.location("earth")!, content.location("mars")!];
|
||||||
const gameDay = (24 * 3_600_000) / content.config.timeCompression;
|
const gameDay = (24 * 3_600_000) / content.config.timeCompression;
|
||||||
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
|
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
|
||||||
const g = realToGame(t, content.config);
|
|
||||||
if (
|
if (
|
||||||
checkRoute(clipper, earth, mars, g).ok &&
|
checkRoute(clipper, earth, mars).ok &&
|
||||||
!wouldStrand(clipper, earth, mars, 30, g, new Set(["earth", "moon", "mars"]), content)
|
!wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content)
|
||||||
) {
|
) {
|
||||||
// Well inside the window: a few game days in.
|
// Well inside the window: a few game days in.
|
||||||
return t + 3 * gameDay;
|
return t + 3 * gameDay;
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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.
|
* 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;
|
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
|
||||||
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
|
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
|
||||||
if (idle.length === 0) return false;
|
if (idle.length === 0) return false;
|
||||||
// Effective stats: an engines upgrade can make the window (CR-009).
|
// Effective stats: an engines upgrade can make the window (CR-009).
|
||||||
return idle.every(
|
return idle.every(
|
||||||
(ship) =>
|
(ship) =>
|
||||||
contractTripGameHours(shipStatsOf(ship), contract, now, content) >
|
contractTripGameHours(shipStatsOf(ship), contract, content) >
|
||||||
contract.deliveryWindowGameHours!,
|
contract.deliveryWindowGameHours!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ import {
|
||||||
checkRoute,
|
checkRoute,
|
||||||
effectiveStats,
|
effectiveStats,
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
type EffectiveStats,
|
type EffectiveStats,
|
||||||
type RealTime,
|
|
||||||
type ShipState,
|
type ShipState,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
|
|
||||||
|
|
@ -26,26 +24,21 @@ export function shipModel(id: string): ShipModel {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Route distance if departing at real time `now`, in million km. */
|
/** Route distance in million km; fixed (decision 0.5.93). */
|
||||||
export function distanceAt(originId: string, destinationId: string, now: number): number {
|
export function distanceAt(originId: string, destinationId: string): number {
|
||||||
return routeDistance(
|
return routeDistance(location(originId), location(destinationId));
|
||||||
location(originId),
|
|
||||||
location(destinationId),
|
|
||||||
realToGame(now, content.config),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export function tripRealMs(
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
originId: string,
|
originId: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
massTonnes: number,
|
massTonnes: number,
|
||||||
now: number,
|
|
||||||
) {
|
) {
|
||||||
const hours = travelTimeGameHours(
|
const hours = travelTimeGameHours(
|
||||||
model,
|
model,
|
||||||
distanceAt(originId, destinationId, now),
|
distanceAt(originId, destinationId),
|
||||||
massTonnes,
|
massTonnes,
|
||||||
content.config,
|
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 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. */
|
/** Legs a model can fly (the map is fixed, decision 0.5.93). */
|
||||||
function everInRange(model: ShipModel): Set<string> {
|
function legsInRange(model: ShipModel): Set<string> {
|
||||||
let legs = everCache.get(model.id);
|
let legs = legCache.get(model.id);
|
||||||
if (!legs) {
|
if (!legs) {
|
||||||
legs = new Set();
|
legs = new Set();
|
||||||
for (const from of content.locations) {
|
for (const from of content.locations) {
|
||||||
for (const to of content.locations) {
|
for (const to of content.locations) {
|
||||||
if (from.id !== to.id && REACH_SAMPLES.some((t) => checkRoute(model, from, to, t).ok)) {
|
if (from.id !== to.id && checkRoute(model, from, to).ok) legs.add(legKey(from.id, to.id));
|
||||||
legs.add(legKey(from.id, to.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
everCache.set(model.id, legs);
|
legCache.set(model.id, legs);
|
||||||
}
|
}
|
||||||
return 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
|
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in
|
||||||
* at `now`, legs in range on other dates, and places it can reach only by
|
* range from each origin, and places it can reach only by hopping. On the fixed
|
||||||
* hopping (each leg in range on some date over 16 game years).
|
* 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 {
|
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
||||||
const t = realToGame(now, content.config);
|
const ever = legsInRange(model);
|
||||||
const ever = everInRange(model);
|
|
||||||
const ids = content.locations.map((l) => l.id);
|
const ids = content.locations.map((l) => l.id);
|
||||||
const origins = new Set(originIds);
|
const origins = new Set(originIds);
|
||||||
const nowLegs = originIds.map((originId) => ({
|
const nowLegs = originIds.map((originId) => ({
|
||||||
originId,
|
originId,
|
||||||
destinationIds: ids.filter(
|
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));
|
const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Location } from "@deadhead/content";
|
import type { Location } from "@deadhead/content";
|
||||||
import { content } 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.
|
* 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
|
* distance from its parent in its true direction, since at map scale it would
|
||||||
* overlap it.
|
* overlap it.
|
||||||
*/
|
*/
|
||||||
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
|
export function locationMapPoint(location: Location): MapPoint {
|
||||||
const parentId = location.orbitParent?.locationId;
|
const parentId = location.orbitParent?.locationId;
|
||||||
const parent = parentId ? content.location(parentId) : undefined;
|
const parent = parentId ? content.location(parentId) : undefined;
|
||||||
if (!parent) return toMapPoint(locationPosition(location, gameTime));
|
if (!parent) return toMapPoint(locationPosition(location));
|
||||||
const earth = locationPosition(parent, gameTime);
|
const earth = locationPosition(parent);
|
||||||
const moon = locationPosition(location, gameTime);
|
const moon = locationPosition(location);
|
||||||
const dx = moon.x - earth.x;
|
const dx = moon.x - earth.x;
|
||||||
const dy = -(moon.y - earth.y);
|
const dy = -(moon.y - earth.y);
|
||||||
const length = Math.hypot(dx, dy) || 1;
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { PlayerState, Ship } from "@deadhead/schema";
|
import type { PlayerState, Ship } from "@deadhead/schema";
|
||||||
import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim";
|
import { checkRoute, effectiveStats } from "@deadhead/sim";
|
||||||
|
|
||||||
export interface OriginReach {
|
export interface OriginReach {
|
||||||
originId: string;
|
originId: string;
|
||||||
|
|
@ -31,17 +31,15 @@ export function playerOrigins(state: Pick<PlayerState, "bays" | "ships" | "locat
|
||||||
export function reachersByOrigin(
|
export function reachersByOrigin(
|
||||||
state: Pick<PlayerState, "bays" | "ships" | "locations">,
|
state: Pick<PlayerState, "bays" | "ships" | "locations">,
|
||||||
locationId: string,
|
locationId: string,
|
||||||
now: number,
|
|
||||||
): OriginReach[] {
|
): OriginReach[] {
|
||||||
const target = content.location(locationId);
|
const target = content.location(locationId);
|
||||||
if (!target) return [];
|
if (!target) return [];
|
||||||
const gameTime = realToGame(now, content.config);
|
|
||||||
return playerOrigins(state)
|
return playerOrigins(state)
|
||||||
.filter((id) => id !== locationId)
|
.filter((id) => id !== locationId)
|
||||||
.map((originId) => {
|
.map((originId) => {
|
||||||
const origin = content.location(originId)!;
|
const origin = content.location(originId)!;
|
||||||
const reaches = (stats: Parameters<typeof checkRoute>[0]) =>
|
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 models = content.purchasableShipModels.filter(reaches).map((m) => m.name);
|
||||||
const upgraded = state.ships
|
const upgraded = state.ships
|
||||||
.filter((ship: Ship) => {
|
.filter((ship: Ship) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
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 { locationMapPoint, radialScale, type MapPoint } from "./projection";
|
||||||
import { mapStyle } from "./style";
|
import { mapStyle } from "./style";
|
||||||
|
|
||||||
|
|
@ -64,14 +64,8 @@ export function anchorLocation(id: string) {
|
||||||
* on the line however the map is scaled.
|
* on the line however the map is scaled.
|
||||||
*/
|
*/
|
||||||
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
|
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
|
||||||
const from = locationMapPoint(
|
const from = locationMapPoint(anchorLocation(dispatch.originId));
|
||||||
anchorLocation(dispatch.originId),
|
const to = locationMapPoint(anchorLocation(dispatch.destinationId));
|
||||||
realToGame(dispatch.departureAt, content.config),
|
|
||||||
);
|
|
||||||
const to = locationMapPoint(
|
|
||||||
anchorLocation(dispatch.destinationId),
|
|
||||||
realToGame(dispatch.arrivalAt, content.config),
|
|
||||||
);
|
|
||||||
const progress = dispatchProgress(dispatch, now);
|
const progress = dispatchProgress(dispatch, now);
|
||||||
return {
|
return {
|
||||||
dispatchId: dispatch.id,
|
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. */
|
/** Everything the map draws at real time `now`, in map coordinates. */
|
||||||
export function buildScene(state: PlayerState, now: number): Scene {
|
export function buildScene(state: PlayerState, now: number): Scene {
|
||||||
const gameTime = realToGame(now, content.config);
|
|
||||||
const idleAt = (locationId: string) =>
|
const idleAt = (locationId: string) =>
|
||||||
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
|
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
|
||||||
(state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId
|
(state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId
|
||||||
|
|
@ -98,11 +91,11 @@ export function buildScene(state: PlayerState, now: number): Scene {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locations: content.locations.map((loc) => {
|
locations: content.locations.map((loc) => {
|
||||||
const sun = locationPosition(loc, gameTime);
|
const sun = locationPosition(loc);
|
||||||
return {
|
return {
|
||||||
id: loc.id,
|
id: loc.id,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
point: locationMapPoint(anchorLocation(loc.id), gameTime),
|
point: locationMapPoint(anchorLocation(loc.id)),
|
||||||
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
|
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
|
||||||
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
|
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
|
||||||
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
|
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,7 @@ export function explainProblems(
|
||||||
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
||||||
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
|
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
|
||||||
}
|
}
|
||||||
const route = checkRoute(
|
const route = checkRoute(model, location(contract.originId), location(contract.destinationId));
|
||||||
model,
|
|
||||||
location(contract.originId),
|
|
||||||
location(contract.destinationId),
|
|
||||||
gameNow,
|
|
||||||
);
|
|
||||||
if (!route.ok) {
|
if (!route.ok) {
|
||||||
problems.push(
|
problems.push(
|
||||||
route.reason === "out-of-range"
|
route.reason === "out-of-range"
|
||||||
|
|
@ -110,22 +105,14 @@ export function explainProblems(
|
||||||
: INELIGIBLE_REASONS["cannot-land"],
|
: INELIGIBLE_REASONS["cannot-land"],
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
wouldStrand(
|
wouldStrand(model, location(contract.destinationId), discoveredIdsOf(state), content)
|
||||||
model,
|
|
||||||
location(contract.originId),
|
|
||||||
location(contract.destinationId),
|
|
||||||
contract.massTonnes,
|
|
||||||
gameNow,
|
|
||||||
discoveredIdsOf(state),
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
problems.push(strandedText(contract.destinationId));
|
problems.push(strandedText(contract.destinationId));
|
||||||
}
|
}
|
||||||
// Priority (CR-007): the trip must fit the delivery window.
|
// Priority (CR-007): the trip must fit the delivery window.
|
||||||
const window = contract.priority ? contract.deliveryWindowGameHours : null;
|
const window = contract.priority ? contract.deliveryWindowGameHours : null;
|
||||||
if (window !== null) {
|
if (window !== null) {
|
||||||
const trip = contractTripGameHours(model, contract, now, content);
|
const trip = contractTripGameHours(model, contract, content);
|
||||||
if (trip > window) {
|
if (trip > window) {
|
||||||
problems.push(
|
problems.push(
|
||||||
`Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`,
|
`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.originId,
|
||||||
contract.destinationId,
|
contract.destinationId,
|
||||||
contract.massTonnes,
|
contract.massTonnes,
|
||||||
now,
|
|
||||||
);
|
);
|
||||||
const fuelCost = tripFuelCost(
|
const fuelCost = tripFuelCost(
|
||||||
model,
|
model,
|
||||||
location(contract.originId),
|
location(contract.originId),
|
||||||
location(contract.destinationId),
|
location(contract.destinationId),
|
||||||
gameNow,
|
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
|
// 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:
|
priority:
|
||||||
contract.priority && contract.deliveryWindowGameHours !== null
|
contract.priority && contract.deliveryWindowGameHours !== null
|
||||||
? {
|
? {
|
||||||
tripHours: contractTripGameHours(model, contract, now, content),
|
tripHours: contractTripGameHours(model, contract, content),
|
||||||
windowHours: contract.deliveryWindowGameHours,
|
windowHours: contract.deliveryWindowGameHours,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, Ship } from "@deadhead/schema";
|
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
dispatchProgress,
|
dispatchProgress,
|
||||||
realToGame,
|
|
||||||
tripFuelCost,
|
tripFuelCost,
|
||||||
validateReposition,
|
validateReposition,
|
||||||
type RepositionFailure,
|
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 pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||||
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||||
const discoveredIds = new Set(discovered);
|
const discoveredIds = new Set(discovered);
|
||||||
const gameNow = realToGame(now, content.config);
|
|
||||||
|
|
||||||
const options = discovered
|
const options = discovered
|
||||||
.filter((id) => id !== origin)
|
.filter((id) => id !== origin)
|
||||||
|
|
@ -240,8 +238,8 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
: check.reason === "would-strand"
|
: check.reason === "would-strand"
|
||||||
? strandedText(id)
|
? strandedText(id)
|
||||||
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
||||||
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow, content.config),
|
fuelCost: tripFuelCost(model, location(origin), location(id), content.config),
|
||||||
tripMs: tripRealMs(model, origin, id, 0, now),
|
tripMs: tripRealMs(model, origin, id, 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ function LocationBoard({
|
||||||
Too heavy
|
Too heavy
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
{tooSlowForAll(state, contract, now) && (
|
{tooSlowForAll(state, contract) && (
|
||||||
<Pill tone="rose" icon="clock">
|
<Pill tone="rose" icon="clock">
|
||||||
Too slow
|
Too slow
|
||||||
</Pill>
|
</Pill>
|
||||||
|
|
@ -281,7 +281,7 @@ function LocationBoard({
|
||||||
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
|
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
|
||||||
{contract.massTonnes} t
|
{contract.massTonnes} t
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content, type Location } from "@deadhead/content";
|
import { content, type Location } from "@deadhead/content";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import {} from "../clock/clock";
|
||||||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
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). */
|
/** Every location; details, contracts and HQ leasing once discovered (Steps 9.9, 10B.9). */
|
||||||
export function Locations() {
|
export function Locations() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
|
||||||
const lease = useGameAction("leaseBay");
|
const lease = useGameAction("leaseBay");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,7 +86,7 @@ export function Locations() {
|
||||||
<span className="font-display text-xs tracking-wider text-cyan-300/80">
|
<span className="font-display text-xs tracking-wider text-cyan-300/80">
|
||||||
{location.id === "earth"
|
{location.id === "earth"
|
||||||
? "Home"
|
? "Home"
|
||||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { content, type Location } from "@deadhead/content";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import {} from "../clock/clock";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { cargoName, formatMillionKm, locationName } from "../format";
|
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). */
|
/** A tapped location: details and a link to its contracts once discovered (CR-002). */
|
||||||
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
|
||||||
const location = content.location(locationId)!;
|
const location = content.location(locationId)!;
|
||||||
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
||||||
const hasBay = state.bays.some((b) => b.locationId === locationId);
|
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".
|
// 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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -116,7 +115,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
||||||
{TYPE_LABELS[location.type]} ·{" "}
|
{TYPE_LABELS[location.type]} ·{" "}
|
||||||
{location.id === "earth"
|
{location.id === "earth"
|
||||||
? "Home"
|
? "Home"
|
||||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-ink-400">
|
<p className="text-ink-400">
|
||||||
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
|
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { content, type ShipModel } from "@deadhead/content";
|
import { content, type ShipModel } from "@deadhead/content";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatCredits, formatMillionKm, locationName } from "../format";
|
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||||
import { serverNow } from "../clock/clock";
|
import {} from "../clock/clock";
|
||||||
import { shipReach, shipStatsOf } from "../game";
|
import { shipReach, shipStatsOf } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
|
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 }) {
|
function Reach({ model }: { model: ShipModel }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const origins = [...new Set(state.bays.map((b) => b.locationId))];
|
const origins = [...new Set(state.bays.map((b) => b.locationId))];
|
||||||
const reach = shipReach(model, origins, serverNow());
|
const reach = shipReach(model, origins);
|
||||||
const parts = [
|
const parts = [
|
||||||
...reach.now.map(
|
...reach.now.map(
|
||||||
(leg) =>
|
(leg) =>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
|
||||||
import { deliveryXp, loanerModel } from "@deadhead/sim";
|
import { deliveryXp, loanerModel } from "@deadhead/sim";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { useClock } from "../clock/clock";
|
import {} from "../clock/clock";
|
||||||
import { formatCredits, formatDuration, locationName } from "../format";
|
import { formatCredits, formatDuration, locationName } from "../format";
|
||||||
import { goodName } from "../components/Goods";
|
import { goodName } from "../components/Goods";
|
||||||
import { distanceAt, tripRealMs } from "../game";
|
import { distanceAt, tripRealMs } from "../game";
|
||||||
|
|
@ -20,7 +20,6 @@ export const INTRO_TIP = "tutorial-intro";
|
||||||
export function TutorialBoard() {
|
export function TutorialBoard() {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
|
||||||
const board = useQuery({
|
const board = useQuery({
|
||||||
queryKey: TUTORIAL_BOARD_KEY,
|
queryKey: TUTORIAL_BOARD_KEY,
|
||||||
queryFn: () => api.call("getTutorialBoard", {}),
|
queryFn: () => api.call("getTutorialBoard", {}),
|
||||||
|
|
@ -95,10 +94,9 @@ export function TutorialBoard() {
|
||||||
contract.originId,
|
contract.originId,
|
||||||
contract.destinationId,
|
contract.destinationId,
|
||||||
contract.massTonnes,
|
contract.massTonnes,
|
||||||
now,
|
|
||||||
);
|
);
|
||||||
const xp = deliveryXp(
|
const xp = deliveryXp(
|
||||||
distanceAt(contract.originId, contract.destinationId, now),
|
distanceAt(contract.originId, contract.destinationId),
|
||||||
contract.tier,
|
contract.tier,
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
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 { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -83,11 +83,7 @@ const state = samplePlayerState({
|
||||||
const tripHours = () =>
|
const tripHours = () =>
|
||||||
travelTimeGameHours(
|
travelTimeGameHours(
|
||||||
content.shipModel("courier")!,
|
content.shipModel("courier")!,
|
||||||
routeDistance(
|
routeDistance(content.location("earth")!, content.location("moon")!),
|
||||||
content.location("earth")!,
|
|
||||||
content.location("moon")!,
|
|
||||||
realToGame(NOW, content.config),
|
|
||||||
),
|
|
||||||
moonJob.massTonnes,
|
moonJob.massTonnes,
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const rush: Contract = {
|
||||||
goodId: "mail",
|
goodId: "mail",
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
priority: true,
|
priority: true,
|
||||||
deliveryWindowGameHours: priorityWindow(earth, moon, 6, NOW, content),
|
deliveryWindowGameHours: priorityWindow(earth, moon, 6, content),
|
||||||
tier: 0,
|
tier: 0,
|
||||||
massTonnes: 6,
|
massTonnes: 6,
|
||||||
payout: 1_500,
|
payout: 1_500,
|
||||||
|
|
@ -62,8 +62,7 @@ const rush: Contract = {
|
||||||
firstContact: false,
|
firstContact: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const trip = (modelId: string) =>
|
const trip = (modelId: string) => contractTripGameHours(content.shipModel(modelId)!, rush, content);
|
||||||
contractTripGameHours(content.shipModel(modelId)!, rush, NOW, content);
|
|
||||||
|
|
||||||
async function openSheet() {
|
async function openSheet() {
|
||||||
renderApp(
|
renderApp(
|
||||||
|
|
|
||||||
|
|
@ -116,17 +116,11 @@ describe("dispatch sheet", () => {
|
||||||
const model = content.shipModel("freighter")!;
|
const model = content.shipModel("freighter")!;
|
||||||
const earth = content.location("earth")!;
|
const earth = content.location("earth")!;
|
||||||
const mars = content.location("mars")!;
|
const mars = content.location("mars")!;
|
||||||
const gameNow = realToGame(NOW, content.config);
|
|
||||||
const tripMs =
|
const tripMs =
|
||||||
(travelTimeGameHours(
|
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
|
||||||
model,
|
|
||||||
routeDistance(earth, mars, gameNow),
|
|
||||||
marsJob.massTonnes,
|
|
||||||
content.config,
|
|
||||||
) *
|
|
||||||
MS_PER_HOUR) /
|
MS_PER_HOUR) /
|
||||||
content.config.timeCompression;
|
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);
|
const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config);
|
||||||
|
|
||||||
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => {
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(content.location("earth")!, content.location("mars")!);
|
||||||
content.location("earth")!,
|
|
||||||
content.location("mars")!,
|
|
||||||
realToGame(NOW, content.config),
|
|
||||||
);
|
|
||||||
// Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map.
|
// Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map.
|
||||||
expect(distance).toBeGreaterThan(145);
|
expect(distance).toBeGreaterThan(145);
|
||||||
expect(distance).toBeLessThan(215);
|
expect(distance).toBeLessThan(215);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { checkRoute, realToGame } from "@deadhead/sim";
|
import { checkRoute } from "@deadhead/sim";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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" });
|
const panel = screen.getByRole("dialog", { name: "Mars" });
|
||||||
expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock.");
|
expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock.");
|
||||||
|
|
||||||
const gameTime = realToGame(NOW, content.config);
|
|
||||||
const expected = content.purchasableShipModels
|
const expected = content.purchasableShipModels
|
||||||
.filter(
|
.filter((m) => checkRoute(m, content.location("earth")!, content.location("mars")!).ok)
|
||||||
(m) => checkRoute(m, content.location("earth")!, content.location("mars")!, gameTime).ok,
|
|
||||||
)
|
|
||||||
.map((m) => m.name);
|
.map((m) => m.name);
|
||||||
expect(expected.length).toBeGreaterThan(0);
|
expect(expected.length).toBeGreaterThan(0);
|
||||||
expect(expected).not.toContain("Courier");
|
expect(expected).not.toContain("Courier");
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,11 @@ describe("radial scale", () => {
|
||||||
describe("map fits a phone", () => {
|
describe("map fits a phone", () => {
|
||||||
// A 375 px wide phone, minus header and tab bar.
|
// A 375 px wide phone, minus header and tab bar.
|
||||||
const viewport = { width: 375, height: 690 };
|
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", () => {
|
it("shows all five locations on screen at default zoom, across 12 years", () => {
|
||||||
for (let month = 0; month < 12 * 12; month++) {
|
for (let month = 0; month < 12 * 12; month++) {
|
||||||
const gameTime = start + month * 30 * 86_400_000;
|
|
||||||
for (const location of content.locations) {
|
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).toBeGreaterThanOrEqual(0);
|
||||||
expect(p.x).toBeLessThanOrEqual(viewport.width);
|
expect(p.x).toBeLessThanOrEqual(viewport.width);
|
||||||
expect(p.y).toBeGreaterThanOrEqual(0);
|
expect(p.y).toBeGreaterThanOrEqual(0);
|
||||||
|
|
@ -44,8 +42,8 @@ describe("map fits a phone", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws the Moon apart from Earth", () => {
|
it("draws the Moon apart from Earth", () => {
|
||||||
const earth = locationMapPoint(content.location("earth")!, start);
|
const earth = locationMapPoint(content.location("earth")!);
|
||||||
const moon = locationMapPoint(content.location("moon")!, start);
|
const moon = locationMapPoint(content.location("moon")!);
|
||||||
const a = toScreen(earth, viewport, DEFAULT_CAMERA);
|
const a = toScreen(earth, viewport, DEFAULT_CAMERA);
|
||||||
const b = toScreen(moon, viewport, DEFAULT_CAMERA);
|
const b = toScreen(moon, viewport, DEFAULT_CAMERA);
|
||||||
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5);
|
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const [mars, ceres] = [content.location("mars")!, content.location("ceres-statio
|
||||||
const NOW = (() => {
|
const NOW = (() => {
|
||||||
for (let i = 0; i <= 16 * 12; i++) {
|
for (let i = 0; i <= 16 * 12; i++) {
|
||||||
const t = gameStart + i * MONTH;
|
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");
|
throw new Error("no such date");
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
|
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 { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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.shipModel("courier")!,
|
||||||
content.location("earth")!,
|
content.location("earth")!,
|
||||||
content.location("moon")!,
|
content.location("moon")!,
|
||||||
realToGame(NOW, content.config),
|
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
const share = pilotShare(job.payout, fuel, content.config);
|
const share = pilotShare(job.payout, fuel, content.config);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
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 { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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
|
// 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.
|
// Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered.
|
||||||
const gameNow = realToGame(NOW, content.config);
|
|
||||||
if (
|
if (
|
||||||
!checkRoute(freighter, moon, jupiter, gameNow).ok ||
|
!checkRoute(freighter, moon, jupiter).ok ||
|
||||||
!wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content)
|
!wouldStrand(freighter, jupiter, discovered, content)
|
||||||
) {
|
) {
|
||||||
throw new Error("the scenario no longer strands");
|
throw new Error("the scenario no longer strands");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract } from "@deadhead/schema";
|
import type { Contract } from "@deadhead/schema";
|
||||||
import {
|
import { deliveryXp, MS_PER_HOUR, routeDistance, travelTimeGameHours } from "@deadhead/sim";
|
||||||
deliveryXp,
|
|
||||||
MS_PER_HOUR,
|
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
|
||||||
travelTimeGameHours,
|
|
||||||
} from "@deadhead/sim";
|
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -23,7 +17,6 @@ function expected(contract: Contract) {
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(
|
||||||
content.location(contract.originId)!,
|
content.location(contract.originId)!,
|
||||||
content.location(contract.destinationId)!,
|
content.location(contract.destinationId)!,
|
||||||
realToGame(NOW, content.config),
|
|
||||||
);
|
);
|
||||||
const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
|
const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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).
|
- [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.3 — Content: fuel
|
||||||
- [ ] Step 15.4 — Sim: tank, burn, and refuel
|
- [ ] Step 15.4 — Sim: tank, burn, and refuel
|
||||||
- [ ] Step 15.5 — Sim: stranding and emergency fuel
|
- [ ] Step 15.5 — Sim: stranding and emergency fuel
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ export const gameConfig: GameConfig = {
|
||||||
savedDispatchRetentionRealHours: 168,
|
savedDispatchRetentionRealHours: 168,
|
||||||
// Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84).
|
// Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84).
|
||||||
economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 },
|
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.
|
// Ship upgrades (CR-009): level 1, 2, 3.
|
||||||
upgradeLevels: [
|
upgradeLevels: [
|
||||||
{ costPercentOfPrice: 10, shopRealMinutes: 10 },
|
{ costPercentOfPrice: 10, shopRealMinutes: 10 },
|
||||||
|
|
|
||||||
|
|
@ -246,8 +246,6 @@ export const gameConfigSchema = z
|
||||||
referenceMillionKm: z.number().positive(),
|
referenceMillionKm: z.number().positive(),
|
||||||
exponent: z.number().positive().max(1),
|
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
|
* Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and
|
||||||
* real minutes in the shop (CR-009).
|
* real minutes in the shop (CR-009).
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ const validGameConfig = {
|
||||||
contractBoardSize: 8,
|
contractBoardSize: 8,
|
||||||
tutorialBoardSize: 50,
|
tutorialBoardSize: 50,
|
||||||
contractBoardPeriodRealMinutes: 60,
|
contractBoardPeriodRealMinutes: 60,
|
||||||
noStrandMarginGameHours: 48,
|
|
||||||
dutyLimitGameHours: 12,
|
dutyLimitGameHours: 12,
|
||||||
longHaulDutyBonusGameHours: 2,
|
longHaulDutyBonusGameHours: 2,
|
||||||
restGameHours: 60,
|
restGameHours: 60,
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ describe("Priority contracts (CR-007)", () => {
|
||||||
async function priorityJob(o: Owner, id: string, slackHours: number) {
|
async function priorityJob(o: Owner, id: string, slackHours: number) {
|
||||||
const { clock } = testApp();
|
const { clock } = testApp();
|
||||||
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
|
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 };
|
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
|
||||||
await putBoard(testApp().app, [contract], clock.now);
|
await putBoard(testApp().app, [contract], clock.now);
|
||||||
return contract;
|
return contract;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
|
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
|
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 { describe, expect, it } from "vitest";
|
||||||
import { TEST_NOW, useTestApp, call, getState, edit, setCredits, signUpOwner } from "../support";
|
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 () => {
|
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
|
||||||
const { app, clock } = testApp();
|
const { app } = testApp();
|
||||||
const owner = await signUpOwner(app);
|
const owner = await signUpOwner(app);
|
||||||
await setCredits(app, 100_000);
|
await setCredits(app, 100_000);
|
||||||
// A Freighter with maxed tanks at the Moon, with Jupiter discovered but not
|
// 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("moon")!,
|
||||||
content.location("jupiter-cloud-platform")!,
|
content.location("jupiter-cloud-platform")!,
|
||||||
];
|
];
|
||||||
const now = realToGame(clock.now, content.config);
|
expect(checkRoute(freighter, moon, jupiter).ok).toBe(true);
|
||||||
expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true);
|
|
||||||
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
|
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 before = await getState(app, owner);
|
||||||
const response = await call(app, owner, "POST", "/api/dispatches", {
|
const response = await call(app, owner, "POST", "/api/dispatches", {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
import {
|
import {
|
||||||
effectiveStats,
|
effectiveStats,
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
upgradeCost,
|
upgradeCost,
|
||||||
|
|
@ -93,11 +92,7 @@ describe("starting an upgrade (Step 10I.5)", () => {
|
||||||
const flown = await dispatch();
|
const flown = await dispatch();
|
||||||
expect(flown.statusCode).toBe(200);
|
expect(flown.statusCode).toBe(200);
|
||||||
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
|
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(content.location("earth")!, content.location("moon")!);
|
||||||
content.location("earth")!,
|
|
||||||
content.location("moon")!,
|
|
||||||
realToGame(until, content.config),
|
|
||||||
);
|
|
||||||
const hours = travelTimeGameHours(
|
const hours = travelTimeGameHours(
|
||||||
effectiveStats(freighterModel, { engines: 1 }, content),
|
effectiveStats(freighterModel, { engines: 1 }, content),
|
||||||
distance,
|
distance,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
nextUpgradeLevel,
|
nextUpgradeLevel,
|
||||||
pilotLevel,
|
pilotLevel,
|
||||||
pilotShare,
|
pilotShare,
|
||||||
realToGame,
|
|
||||||
resolveDispatch,
|
resolveDispatch,
|
||||||
restEndsAt,
|
restEndsAt,
|
||||||
skillPointsAvailable,
|
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). */
|
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
|
||||||
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
|
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
|
||||||
const departure = realToGame(now, config);
|
const fuel = tripFuelCost(stats, loc(contract.originId), loc(contract.destinationId), config);
|
||||||
const fuel = tripFuelCost(
|
|
||||||
stats,
|
|
||||||
loc(contract.originId),
|
|
||||||
loc(contract.destinationId),
|
|
||||||
departure,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
|
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
|
||||||
const tripMs =
|
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);
|
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
|
const target = deadheads
|
||||||
.map((to) => {
|
.map((to) => {
|
||||||
const best = board(to)
|
const best = board(to)
|
||||||
.filter(
|
.filter((c) => checkRoute(stats, loc(to), loc(c.destinationId)).ok)
|
||||||
(c) => checkRoute(stats, loc(to), loc(c.destinationId), realToGame(now, config)).ok,
|
|
||||||
)
|
|
||||||
.map((c) => c.payout)
|
.map((c) => c.payout)
|
||||||
.sort((a, b) => b - a)[0];
|
.sort((a, b) => b - a)[0];
|
||||||
return { to, best: best ?? 0 };
|
return { to, best: best ?? 0 };
|
||||||
|
|
@ -643,13 +633,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
discovered.has(ship.locationId) &&
|
discovered.has(ship.locationId) &&
|
||||||
board(ship.locationId).some(
|
board(ship.locationId).some(
|
||||||
(c) =>
|
(c) =>
|
||||||
tripFuelCost(
|
tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
|
||||||
model(ship.modelId),
|
credits,
|
||||||
loc(c.originId),
|
|
||||||
loc(c.destinationId),
|
|
||||||
realToGame(now, config),
|
|
||||||
config,
|
|
||||||
) <= credits,
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
Skill,
|
Skill,
|
||||||
Upgrade,
|
Upgrade,
|
||||||
} from "@deadhead/content";
|
} 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 { contractPayout, type Contract } from "./contract";
|
||||||
import { routeDistance } from "./distance";
|
import { routeDistance } from "./distance";
|
||||||
import { checkRoute } from "./eligibility";
|
import { checkRoute } from "./eligibility";
|
||||||
|
|
@ -125,7 +125,6 @@ function fitJob(
|
||||||
offeredAt: RealTime,
|
offeredAt: RealTime,
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
) {
|
) {
|
||||||
const gameTime = realToGame(offeredAt, content.config);
|
|
||||||
const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min);
|
const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min);
|
||||||
if (able.length === 0) return null;
|
if (able.length === 0) return null;
|
||||||
const start = rng.int(0, able.length - 1);
|
const start = rng.int(0, able.length - 1);
|
||||||
|
|
@ -133,8 +132,8 @@ function fitJob(
|
||||||
const { model, skills } = able[(start + k) % able.length]!;
|
const { model, skills } = able[(start + k) % able.length]!;
|
||||||
const reachable = discovered.filter(
|
const reachable = discovered.filter(
|
||||||
(d) =>
|
(d) =>
|
||||||
checkRoute(model, origin, d, gameTime).ok &&
|
checkRoute(model, origin, d).ok &&
|
||||||
requiredLongHaulLevel(routeDistance(origin, d, gameTime), content.config) <=
|
requiredLongHaulLevel(routeDistance(origin, d), content.config) <=
|
||||||
skillLevel(skills, LONG_HAUL_SKILL_ID),
|
skillLevel(skills, LONG_HAUL_SKILL_ID),
|
||||||
);
|
);
|
||||||
if (reachable.length === 0) continue;
|
if (reachable.length === 0) continue;
|
||||||
|
|
@ -266,13 +265,12 @@ export function priorityWindow(
|
||||||
origin: Location,
|
origin: Location,
|
||||||
destination: Location,
|
destination: Location,
|
||||||
massTonnes: number,
|
massTonnes: number,
|
||||||
offeredAt: RealTime,
|
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): number {
|
): number {
|
||||||
const { config } = content;
|
const { config } = content;
|
||||||
const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId);
|
const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId);
|
||||||
if (!reference) throw new Error(`Unknown ship model ${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;
|
return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,19 +292,9 @@ function rollPriority(
|
||||||
): { priority: boolean; deliveryWindowGameHours: number | null } {
|
): { priority: boolean; deliveryWindowGameHours: number | null } {
|
||||||
const none = { priority: false, deliveryWindowGameHours: null };
|
const none = { priority: false, deliveryWindowGameHours: null };
|
||||||
if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none;
|
if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none;
|
||||||
const window = priorityWindow(
|
const window = priorityWindow(args.origin, args.destination, args.massTonnes, content);
|
||||||
args.origin,
|
|
||||||
args.destination,
|
|
||||||
args.massTonnes,
|
|
||||||
args.offeredAt,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
if (args.model) {
|
if (args.model) {
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(args.origin, args.destination);
|
||||||
args.origin,
|
|
||||||
args.destination,
|
|
||||||
realToGame(args.offeredAt, content.config),
|
|
||||||
);
|
|
||||||
const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config);
|
const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config);
|
||||||
if (trip > window) return none;
|
if (trip > window) return none;
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +346,7 @@ export function buildContract(
|
||||||
const { config } = content;
|
const { config } = content;
|
||||||
const cargoType = cargoClassOf(args.good, content);
|
const cargoType = cargoClassOf(args.good, content);
|
||||||
const offeredAt = args.periodIndex * boardPeriodMs(config);
|
const offeredAt = args.periodIndex * boardPeriodMs(config);
|
||||||
const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config));
|
const distance = routeDistance(args.origin, args.destination);
|
||||||
return {
|
return {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
originId: args.origin.id,
|
originId: args.origin.id,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { GameConfig, Location, ShipModel } from "@deadhead/content";
|
import type { GameConfig, Location, ShipModel } from "@deadhead/content";
|
||||||
import type { SimContent } from "./board";
|
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 type { Contract } from "./contract";
|
||||||
import { routeDistance } from "./distance";
|
import { routeDistance } from "./distance";
|
||||||
import { isResting } from "./duty";
|
import { isResting } from "./duty";
|
||||||
|
|
@ -88,56 +88,47 @@ export function findLocation(id: string, content: SimContent): Location {
|
||||||
return 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(
|
export function tripFuelCost(
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
origin: Location,
|
origin: Location,
|
||||||
destination: Location,
|
destination: Location,
|
||||||
departure: GameTime,
|
|
||||||
config: GameConfig,
|
config: GameConfig,
|
||||||
): number {
|
): 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 });
|
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(
|
export function contractTripGameHours(
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
now: RealTime,
|
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): number {
|
): number {
|
||||||
const origin = findLocation(contract.originId, content);
|
const origin = findLocation(contract.originId, content);
|
||||||
const destination = findLocation(contract.destinationId, 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);
|
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The no-strand rule (decision 0.5.79): true when, at arrival plus the configured
|
* The no-strand rule (decision 0.5.79): true when the ship could not fly from
|
||||||
* margin, the ship could not fly from the destination to any other discovered
|
* the destination to any other discovered location. `discoveredIds` absent
|
||||||
* location. `discoveredIds` absent means every location counts.
|
* means every location counts. The map is fixed (0.5.93), so arrival time
|
||||||
|
* doesn't matter.
|
||||||
*/
|
*/
|
||||||
export function wouldStrand(
|
export function wouldStrand(
|
||||||
/** Effective stats, or a bare model (handling falls back to the game-wide base). */
|
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
origin: Location,
|
|
||||||
destination: Location,
|
destination: Location,
|
||||||
massTonnes: number,
|
|
||||||
departure: GameTime,
|
|
||||||
discoveredIds: ReadonlySet<string> | undefined,
|
discoveredIds: ReadonlySet<string> | undefined,
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): boolean {
|
): 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(
|
return !content.locations.some(
|
||||||
(next) =>
|
(next) =>
|
||||||
next.id !== destination.id &&
|
next.id !== destination.id &&
|
||||||
(discoveredIds?.has(next.id) ?? true) &&
|
(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 model = shipStats(ship, content);
|
||||||
const origin = findLocation(contract.originId, content);
|
const origin = findLocation(contract.originId, content);
|
||||||
const destination = findLocation(contract.destinationId, content);
|
const destination = findLocation(contract.destinationId, content);
|
||||||
const departure = realToGame(now, content.config);
|
|
||||||
|
|
||||||
if (ship.inFlight) return fail("ship-not-idle");
|
if (ship.inFlight) return fail("ship-not-idle");
|
||||||
if (isInShop(ship, now)) return fail("ship-in-shop");
|
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");
|
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 (!route.ok) return fail(route.reason);
|
||||||
if (
|
if (wouldStrand(model, destination, proposal.discoveredIds, content)) {
|
||||||
wouldStrand(
|
|
||||||
model,
|
|
||||||
origin,
|
|
||||||
destination,
|
|
||||||
contract.massTonnes,
|
|
||||||
departure,
|
|
||||||
proposal.discoveredIds,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return fail("would-strand");
|
return fail("would-strand");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +161,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
|
||||||
if (
|
if (
|
||||||
contract.priority &&
|
contract.priority &&
|
||||||
contract.deliveryWindowGameHours !== null &&
|
contract.deliveryWindowGameHours !== null &&
|
||||||
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours
|
contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours
|
||||||
) {
|
) {
|
||||||
return fail("too-slow");
|
return fail("too-slow");
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +172,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
|
||||||
if (now >= contract.expiresAt) return fail("contract-expired");
|
if (now >= contract.expiresAt) return fail("contract-expired");
|
||||||
|
|
||||||
if (!tutorial) {
|
if (!tutorial) {
|
||||||
if (credits < tripFuelCost(model, origin, destination, departure, content.config))
|
if (credits < tripFuelCost(model, origin, destination, content.config))
|
||||||
return fail("insufficient-credits");
|
return fail("insufficient-credits");
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -247,8 +227,7 @@ function buildRecord(
|
||||||
const model = shipStats(ship, content);
|
const model = shipStats(ship, content);
|
||||||
const origin = findLocation(args.originId, content);
|
const origin = findLocation(args.originId, content);
|
||||||
const destination = findLocation(args.destinationId, content);
|
const destination = findLocation(args.destinationId, content);
|
||||||
const departure = realToGame(now, config);
|
const distance = routeDistance(origin, destination);
|
||||||
const distance = routeDistance(origin, destination, departure);
|
|
||||||
const mass = contract?.massTonnes ?? 0;
|
const mass = contract?.massTonnes ?? 0;
|
||||||
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
|
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
|
||||||
|
|
||||||
|
|
@ -263,7 +242,7 @@ function buildRecord(
|
||||||
departureAt: now,
|
departureAt: now,
|
||||||
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
||||||
distanceMillionKm: distance,
|
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,
|
expectedPayout: contract?.payout ?? 0,
|
||||||
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
||||||
};
|
};
|
||||||
|
|
@ -329,14 +308,13 @@ export function validateReposition(
|
||||||
const model = shipStats(ship, content);
|
const model = shipStats(ship, content);
|
||||||
const origin = findLocation(ship.locationId, content);
|
const origin = findLocation(ship.locationId, content);
|
||||||
const destination = findLocation(destinationId, content);
|
const destination = findLocation(destinationId, content);
|
||||||
const departure = realToGame(now, content.config);
|
const route = checkRoute(model, origin, destination);
|
||||||
const route = checkRoute(model, origin, destination, departure);
|
|
||||||
if (!route.ok) return failWith(route.reason);
|
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");
|
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 failWith("insufficient-credits");
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { Location } from "@deadhead/content";
|
import type { Location } from "@deadhead/content";
|
||||||
import type { GameTime } from "./clock";
|
|
||||||
import { locationPosition } from "./positions";
|
import { locationPosition } from "./positions";
|
||||||
|
|
||||||
/** Straight-line distance between two locations at a game time, in million km. */
|
/** Straight-line distance between two locations, in million km; fixed (decision 0.5.93). */
|
||||||
export function routeDistance(from: Location, to: Location, gameTime: GameTime): number {
|
export function routeDistance(from: Location, to: Location): number {
|
||||||
const a = locationPosition(from, gameTime);
|
const a = locationPosition(from);
|
||||||
const b = locationPosition(to, gameTime);
|
const b = locationPosition(to);
|
||||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { LandingClass, Location, ShipModel } from "@deadhead/content";
|
import type { LandingClass, Location, ShipModel } from "@deadhead/content";
|
||||||
import type { GameTime } from "./clock";
|
|
||||||
import { routeDistance } from "./distance";
|
import { routeDistance } from "./distance";
|
||||||
|
|
||||||
const LANDING_RANK: Record<LandingClass, number> = { vacuum: 0, atmospheric: 1, hardened: 2 };
|
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
|
* Whether a ship can fly from one location to another, or why not. Range is
|
||||||
* not. Range is per leg, from the origin, at departure (decision 0.5.77).
|
* per leg, from the origin (decision 0.5.77); the map is fixed (0.5.93).
|
||||||
*/
|
*/
|
||||||
export function checkRoute(
|
export function checkRoute(ship: ShipModel, from: Location, to: Location): RouteCheck {
|
||||||
ship: ShipModel,
|
|
||||||
from: Location,
|
|
||||||
to: Location,
|
|
||||||
gameTime: GameTime,
|
|
||||||
): RouteCheck {
|
|
||||||
if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" };
|
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: false, reason: "out-of-range" };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { SimContent } from "./board";
|
import type { SimContent } from "./board";
|
||||||
import { type RealTime, realToGame } from "./clock";
|
import type { RealTime } from "./clock";
|
||||||
import { type DispatchRecord, findLocation } from "./dispatch";
|
import { type DispatchRecord, findLocation } from "./dispatch";
|
||||||
import { locationPosition, type Position } from "./positions";
|
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));
|
return Math.min(1, Math.max(0, (now - record.departureAt) / duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Ship position at a real time: along the straight line from origin to destination. */
|
||||||
* Ship position at a real time: a straight line from the origin's position at
|
|
||||||
* departure to the destination's position at arrival.
|
|
||||||
*/
|
|
||||||
export function dispatchPosition(
|
export function dispatchPosition(
|
||||||
record: DispatchRecord,
|
record: DispatchRecord,
|
||||||
now: RealTime,
|
now: RealTime,
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): Position {
|
): Position {
|
||||||
const { config } = content;
|
const start = locationPosition(findLocation(record.originId, content));
|
||||||
const start = locationPosition(
|
const end = locationPosition(findLocation(record.destinationId, content));
|
||||||
findLocation(record.originId, content),
|
|
||||||
realToGame(record.departureAt, config),
|
|
||||||
);
|
|
||||||
const end = locationPosition(
|
|
||||||
findLocation(record.destinationId, content),
|
|
||||||
realToGame(record.arrivalAt, config),
|
|
||||||
);
|
|
||||||
const t = dispatchProgress(record, now);
|
const t = dispatchProgress(record, now);
|
||||||
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
|
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { FixedPosition, Location } from "@deadhead/content";
|
import type { FixedPosition, Location } from "@deadhead/content";
|
||||||
import type { GameTime } from "./clock";
|
|
||||||
|
|
||||||
/** A heliocentric point in the plane of the map, in million km. */
|
/** A heliocentric point in the plane of the map, in million km. */
|
||||||
export interface Position {
|
export interface Position {
|
||||||
|
|
@ -16,10 +15,7 @@ export function fixedPoint(position: FixedPosition): Position {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A location's position in million km from the Sun; it never moves (decision 0.5.93). */
|
||||||
* A location's position in million km from the Sun. The map is fixed
|
export function locationPosition(location: Location): Position {
|
||||||
* (decision 0.5.93); the game time is ignored and goes away in Step 15.2.
|
|
||||||
*/
|
|
||||||
export function locationPosition(location: Location, _gameTime?: GameTime): Position {
|
|
||||||
return fixedPoint(location.position);
|
return fixedPoint(location.position);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
|
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
|
||||||
import { content } from "@deadhead/content";
|
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
|
* 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.
|
// Step 12.2: the tutorial share and starting credits as actually earned.
|
||||||
const courier = content.shipModel("courier")!;
|
const courier = content.shipModel("courier")!;
|
||||||
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
|
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, content.config);
|
||||||
const fuel = tripFuelCost(courier, earth, moon, start, content.config);
|
|
||||||
for (const result of lighterFirst) {
|
for (const result of lighterFirst) {
|
||||||
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
|
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
|
||||||
expect(result.tutorialShareEarned).toBeGreaterThan(0);
|
expect(result.tutorialShareEarned).toBeGreaterThan(0);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
checkRoute,
|
checkRoute,
|
||||||
contractTripGameHours,
|
contractTripGameHours,
|
||||||
generateContractBoard,
|
generateContractBoard,
|
||||||
realToGame,
|
|
||||||
type BoardInput,
|
type BoardInput,
|
||||||
type Contract,
|
type Contract,
|
||||||
type FleetMember,
|
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). */
|
/** Whether this crew can fly the contract (the sim's own route and pilot rules). */
|
||||||
function fits(contract: Contract, member: FleetMember): boolean {
|
function fits(contract: Contract, member: FleetMember): boolean {
|
||||||
const model = content.shipModel(member.modelId)!;
|
const model = content.shipModel(member.modelId)!;
|
||||||
const gameTime = realToGame(contract.offeredAt, config);
|
|
||||||
return (
|
return (
|
||||||
!contract.firstContact &&
|
!contract.firstContact &&
|
||||||
contract.massTonnes <= model.cargoCapacityTonnes &&
|
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 &&
|
checkPilotEligibility(member.skills, contract).ok &&
|
||||||
// Priority (CR-007): the ship must make the delivery window.
|
// Priority (CR-007): the ship must make the delivery window.
|
||||||
(!contract.priority ||
|
(!contract.priority ||
|
||||||
contractTripGameHours(model, contract, contract.offeredAt, content) <=
|
contractTripGameHours(model, contract, content) <= contract.deliveryWindowGameHours!)
|
||||||
contract.deliveryWindowGameHours!)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ describe("goods in board generation (CR-007)", () => {
|
||||||
priority++;
|
priority++;
|
||||||
expect(good.priorityEligible).toBe(true);
|
expect(good.priorityEligible).toBe(true);
|
||||||
expect(c.deliveryWindowGameHours).toBeCloseTo(
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ const tanks = content.upgrade("fuel-tanks")!;
|
||||||
const earth = content.location("earth")!;
|
const earth = content.location("earth")!;
|
||||||
const moon = content.location("moon")!;
|
const moon = content.location("moon")!;
|
||||||
const mars = content.location("mars")!;
|
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). */
|
/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */
|
||||||
const withTanks = (level: number): ShipModel => ({
|
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.
|
// 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)", () => {
|
describe("Courier to Mars (decision 0.5.68)", () => {
|
||||||
it("reaches Mars from Earth with maxed fuel tanks", () => {
|
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", () => {
|
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]) {
|
for (const level of [0, 1, 2]) {
|
||||||
expect(checkRoute(withTanks(level), earth, mars, now).ok, `level ${level}`).toBe(false);
|
expect(checkRoute(withTanks(level), earth, mars).ok, `level ${level}`).toBe(false);
|
||||||
expect(checkRoute(withTanks(level), moon, mars, now).ok, `level ${level}`).toBe(false);
|
expect(checkRoute(withTanks(level), moon, mars).ok, `level ${level}`).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,29 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { routeDistance } from "../src/index";
|
import { locationPosition, routeDistance } from "../src/index";
|
||||||
|
|
||||||
const earth = content.location("earth")!;
|
const earth = content.location("earth")!;
|
||||||
const moon = content.location("moon")!;
|
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", () => {
|
describe("route distance", () => {
|
||||||
it("keeps Earth→Moon under 1 million km at any time", () => {
|
it("keeps Earth→Moon under 1 million km", () => {
|
||||||
for (const t of monthly) {
|
expect(routeDistance(earth, moon)).toBeLessThan(1);
|
||||||
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("is symmetric", () => {
|
it("is symmetric", () => {
|
||||||
for (const a of content.locations) {
|
for (const a of content.locations) {
|
||||||
for (const b 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { content, SHIP_STATS } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
effectiveStats,
|
effectiveStats,
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
routeDistance,
|
||||||
shipStats,
|
shipStats,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
|
|
@ -86,11 +85,7 @@ describe("effective stats (Step 10I.2)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets an engines upgrade make a Priority window that was too short", () => {
|
it("lets an engines upgrade make a Priority window that was too short", () => {
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(content.location("earth")!, content.location("moon")!);
|
||||||
content.location("earth")!,
|
|
||||||
content.location("moon")!,
|
|
||||||
realToGame(now, content.config),
|
|
||||||
);
|
|
||||||
const hours = (stats: typeof courier) =>
|
const hours = (stats: typeof courier) =>
|
||||||
travelTimeGameHours(stats, distance, contract.massTonnes, content.config);
|
travelTimeGameHours(stats, distance, contract.massTonnes, content.config);
|
||||||
const slow = hours(effectiveStats(courier, {}, content));
|
const slow = hours(effectiveStats(courier, {}, content));
|
||||||
|
|
|
||||||
|
|
@ -15,38 +15,37 @@ const bulkHauler = content.shipModel("bulk-hauler")!;
|
||||||
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
|
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
|
||||||
|
|
||||||
// The map is fixed (decision 0.5.93), so any time gives the same answer.
|
// 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" };
|
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).
|
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
|
||||||
describe("route eligibility", () => {
|
describe("route eligibility", () => {
|
||||||
it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
|
it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
|
||||||
expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true });
|
expect(checkRoute(courier, earth, moon)).toEqual({ ok: true });
|
||||||
expect(checkRoute(courier, moon, earth, now)).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", () => {
|
it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => {
|
||||||
for (const to of [mars, ceres, 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", () => {
|
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,
|
ok: false,
|
||||||
reason: "cannot-land",
|
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)", () => {
|
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, mars)).toEqual({ ok: true });
|
||||||
expect(checkRoute(freighter, earth, ceres, now)).toEqual({ ok: true });
|
expect(checkRoute(freighter, earth, ceres)).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
|
it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
|
||||||
expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true });
|
expect(checkRoute(bulkHauler, earth, jupiter)).toEqual({ ok: true });
|
||||||
expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange);
|
expect(checkRoute(freighter, earth, jupiter)).toEqual(outOfRange);
|
||||||
expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange);
|
expect(checkRoute(courier, earth, jupiter)).toEqual(outOfRange);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
dispatchPosition,
|
dispatchPosition,
|
||||||
dispatchProgress,
|
dispatchProgress,
|
||||||
locationPosition,
|
locationPosition,
|
||||||
realToGame,
|
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
const record = createRepositionRecord(
|
const record = createRepositionRecord(
|
||||||
|
|
@ -42,14 +41,8 @@ describe("in-flight progress", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("in-flight position", () => {
|
describe("in-flight position", () => {
|
||||||
const origin = locationPosition(
|
const origin = locationPosition(content.location("earth")!);
|
||||||
content.location("earth")!,
|
const destination = locationPosition(content.location("moon")!);
|
||||||
realToGame(record.departureAt, content.config),
|
|
||||||
);
|
|
||||||
const destination = locationPosition(
|
|
||||||
content.location("moon")!,
|
|
||||||
realToGame(record.arrivalAt, content.config),
|
|
||||||
);
|
|
||||||
|
|
||||||
it("starts at the origin's position at departure", () => {
|
it("starts at the origin's position at departure", () => {
|
||||||
expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin);
|
expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin);
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,7 @@ describe("economic distance (decision 0.5.84)", () => {
|
||||||
|
|
||||||
describe("fuel cost", () => {
|
describe("fuel cost", () => {
|
||||||
it("costs more Earth→Moon than Moon→Earth for the same ship", () => {
|
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);
|
||||||
const distance = routeDistance(earth, moon, t);
|
|
||||||
expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan(
|
expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan(
|
||||||
fuelCost(courier, moon, distance, config),
|
fuelCost(courier, moon, distance, config),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
checkRoute,
|
checkRoute,
|
||||||
effectiveStats,
|
effectiveStats,
|
||||||
MS_PER_HOUR,
|
|
||||||
routeDistance,
|
|
||||||
travelTimeGameHours,
|
|
||||||
realToGame,
|
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
validateReposition,
|
validateReposition,
|
||||||
wouldStrand,
|
wouldStrand,
|
||||||
|
|
@ -31,7 +27,7 @@ const pilot: PilotState = {
|
||||||
/**
|
/**
|
||||||
* Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can
|
* 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
|
* 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 heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 };
|
||||||
const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" };
|
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 });
|
).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never strands a base ship on a trip it is allowed to take between the five locations", () => {
|
it("never blocks a base ship on a leg it can fly between the five locations (fixed map)", () => {
|
||||||
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 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) {
|
for (const base of content.shipModels) {
|
||||||
const model = effectiveStats(base, {}, content);
|
const model = effectiveStats(base, {}, content);
|
||||||
for (const from of ids) {
|
for (const from of ids) {
|
||||||
for (const to of ids) {
|
for (const to of ids) {
|
||||||
if (from === to) continue;
|
if (from === to || !checkRoute(model, loc(from), loc(to)).ok) continue;
|
||||||
for (let i = 0; i <= 16 * 12; i++) {
|
expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe(
|
||||||
const t = start + i * month;
|
false,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { contractPayout, generateContractBoard, routeDistance, type Contract } from "../src/index";
|
||||||
contractPayout,
|
|
||||||
generateContractBoard,
|
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
|
||||||
type Contract,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
const { config } = content;
|
const { config } = content;
|
||||||
const v = config.contractPayVariance;
|
const v = config.contractPayVariance;
|
||||||
|
|
@ -24,7 +18,6 @@ function basePayout(c: Contract): number {
|
||||||
distanceMillionKm: routeDistance(
|
distanceMillionKm: routeDistance(
|
||||||
content.location(c.originId)!,
|
content.location(c.originId)!,
|
||||||
content.location(c.destinationId)!,
|
content.location(c.destinationId)!,
|
||||||
realToGame(c.offeredAt, config),
|
|
||||||
),
|
),
|
||||||
firstContact: c.firstContact,
|
firstContact: c.firstContact,
|
||||||
priority: c.priority,
|
priority: c.priority,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import { content } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { locationPosition } from "../src/index";
|
import { locationPosition } from "../src/index";
|
||||||
|
|
||||||
const when = Date.parse("2100-03-01T00:00:00Z");
|
|
||||||
const fromSun = (id: string) => {
|
const fromSun = (id: string) => {
|
||||||
const { x, y } = locationPosition(content.location(id)!, when);
|
const { x, y } = locationPosition(content.location(id)!);
|
||||||
return Math.hypot(x, y);
|
return Math.hypot(x, y);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -29,14 +28,14 @@ describe("location positions", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the Moon within 1 million km of Earth", () => {
|
it("keeps the Moon within 1 million km of Earth", () => {
|
||||||
const earth = locationPosition(content.location("earth")!, when);
|
const earth = locationPosition(content.location("earth")!);
|
||||||
const moon = locationPosition(content.location("moon")!, when);
|
const moon = locationPosition(content.location("moon")!);
|
||||||
expect(Math.hypot(moon.x - earth.x, moon.y - earth.y)).toBeLessThan(1);
|
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) {
|
for (const location of content.locations) {
|
||||||
expect(locationPosition(location, when)).toEqual(locationPosition(location, when));
|
expect(locationPosition(location)).toEqual(locationPosition(location));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ function mailContract(priority: boolean): Contract {
|
||||||
periodIndex,
|
periodIndex,
|
||||||
shipper: earth.shippers[0]!,
|
shipper: earth.shippers[0]!,
|
||||||
priority,
|
priority,
|
||||||
deliveryWindowGameHours: priority
|
deliveryWindowGameHours: priority ? priorityWindow(earth, moon, massTonnes, content) : null,
|
||||||
? priorityWindow(earth, moon, massTonnes, offeredAt, content)
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
@ -69,7 +67,7 @@ describe("Priority validation (CR-007)", () => {
|
||||||
it("measures the trip the same way the window was set", () => {
|
it("measures the trip the same way the window was set", () => {
|
||||||
const contract = mailContract(true);
|
const contract = mailContract(true);
|
||||||
const freighter = content.shipModel("freighter")!;
|
const freighter = content.shipModel("freighter")!;
|
||||||
expect(contractTripGameHours(freighter, contract, offeredAt, content)).toBeCloseTo(
|
expect(contractTripGameHours(freighter, contract, content)).toBeCloseTo(
|
||||||
contract.deliveryWindowGameHours!,
|
contract.deliveryWindowGameHours!,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { content, type Location, type ShipModel } from "@deadhead/content";
|
import { content, type Location, type ShipModel } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
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 loc = (id: string) => content.location(id)!;
|
||||||
const courier = content.shipModel("courier")!;
|
const courier = content.shipModel("courier")!;
|
||||||
const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range });
|
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)", () => {
|
describe("range from the origin (Step 10L.2)", () => {
|
||||||
it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => {
|
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", () => {
|
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"));
|
||||||
const d = routeDistance(loc("moon"), loc("mars"), t);
|
|
||||||
const ship = withRange(d / 1.2);
|
const ship = withRange(d / 1.2);
|
||||||
expect(routeDistance(loc("earth"), loc("mars"), t)).toBeGreaterThan(ship.fuelRangeMillionKm);
|
expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(ship.fuelRangeMillionKm);
|
||||||
expect(checkRoute(ship, loc("earth"), loc("mars"), t)).toEqual({
|
expect(checkRoute(ship, loc("earth"), loc("mars"))).toEqual({
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: "out-of-range",
|
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", () => {
|
it("matches the old check exactly when every factor is 1", () => {
|
||||||
const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 });
|
const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 });
|
||||||
const ids = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"];
|
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 from of ids) {
|
||||||
for (const to of ids) {
|
for (const to of ids) {
|
||||||
if (from === to) continue;
|
if (from === to) continue;
|
||||||
for (const t of samples) {
|
const old = routeDistance(loc(from), loc(to)) <= range;
|
||||||
const old = routeDistance(loc(from), loc(to), t) <= range;
|
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to))).ok).toBe(old);
|
||||||
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), t).ok).toBe(old);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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.
|
* (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> = {
|
const NAMES: Record<string, string> = {
|
||||||
earth: "Earth",
|
earth: "Earth",
|
||||||
moon: "Moon",
|
moon: "Moon",
|
||||||
|
|
@ -18,7 +17,7 @@ const ids = Object.keys(NAMES);
|
||||||
|
|
||||||
function reaches(modelId: string, fromId: string, toId: string): boolean {
|
function reaches(modelId: string, fromId: string, toId: string): boolean {
|
||||||
const ship = content.shipModel(modelId)!;
|
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. */
|
/** [model, from, to] for every leg the table says yes or no to. */
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,12 +6,7 @@ const config = content.config;
|
||||||
const courier = content.shipModel("courier")!;
|
const courier = content.shipModel("courier")!;
|
||||||
const freighter = content.shipModel("freighter")!;
|
const freighter = content.shipModel("freighter")!;
|
||||||
const loaner = content.shipModel("company-courier")!;
|
const loaner = content.shipModel("company-courier")!;
|
||||||
const earthMoon = () =>
|
const earthMoon = () => routeDistance(content.location("earth")!, content.location("moon")!);
|
||||||
routeDistance(
|
|
||||||
content.location("earth")!,
|
|
||||||
content.location("moon")!,
|
|
||||||
Date.parse("2100-01-01T00:00:00Z"),
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("travel time", () => {
|
describe("travel time", () => {
|
||||||
it("matches a hand calculation, including handling time", () => {
|
it("matches a hand calculation, including handling time", () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
generateTutorialBoard,
|
generateTutorialBoard,
|
||||||
loanerModel,
|
loanerModel,
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
realToGame,
|
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
type Contract,
|
type Contract,
|
||||||
|
|
@ -25,7 +24,6 @@ function distanceOf(contract: Contract): number {
|
||||||
return routeDistance(
|
return routeDistance(
|
||||||
content.location(contract.originId)!,
|
content.location(contract.originId)!,
|
||||||
content.location(contract.destinationId)!,
|
content.location(contract.destinationId)!,
|
||||||
realToGame(contract.offeredAt, config),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content, upgradeMaxLevel, upgradePercentPerLevel, upgrades } from "@deadhead/content";
|
import { content, upgradeMaxLevel, upgradePercentPerLevel, upgrades } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
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). */
|
/** Every upgrade at its highest level for the model (decision 0.5.63). */
|
||||||
const maxedStats = (modelId: string) =>
|
const maxedStats = (modelId: string) =>
|
||||||
|
|
@ -10,9 +10,6 @@ const maxedStats = (modelId: string) =>
|
||||||
content,
|
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 earth = content.location("earth")!;
|
||||||
const moon = content.location("moon")!;
|
const moon = content.location("moon")!;
|
||||||
const mars = content.location("mars")!;
|
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", () => {
|
it("a maxed Lighter never reaches Mars from Earth or the Moon", () => {
|
||||||
const lighter = maxedStats("lighter");
|
const lighter = maxedStats("lighter");
|
||||||
for (const from of [earth, moon]) {
|
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", () => {
|
it("only Bulk Haulers reach Jupiter from Earth, even maxed", () => {
|
||||||
const farthest = samples.reduce((a, b) =>
|
|
||||||
routeDistance(earth, jupiter, b) > routeDistance(earth, jupiter, a) ? b : a,
|
|
||||||
);
|
|
||||||
for (const ship of content.shipModels) {
|
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");
|
expect(reaches, ship.id).toBe(ship.id === "bulk-hauler");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue