diff --git a/DECISIONS.md b/DECISIONS.md index 09bb54d..f60fc35 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1445,3 +1445,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Ranges moved into CR-015 §4's bands here**, ahead of 15.3's tank conversion, because the fixed map otherwise breaks the game at once (the deal's Clipper, at 198M km, couldn't reach Mars at 201). Chosen with margins inside each band: Clipper 250 (band 201–302 from the §4 cells), Freighter 460 (402–515), Courier tanks +1,400 %/level (level 2 must stay under 154 so it can't reach Mars from the Moon; level 3 at least 201). 15.3 turns these into tank capacity ÷ burn rate with the same reach. - **Lighter price 42,400 → 36,000** (balance re-check, decision 0.5.84's targets): with Mars always 201M km away, purchases land on multiples of the Clipper's ~43-minute first Mars leg, so the median first Market purchase was 86.7 min, outside 45–75. Of the knobs tried (Clipper speed, first-contact pay, Lighter price), the price alone fixes it without touching the reach bands: first purchase 53.3 min, discovery 43.7, loan paid off 135 min, four ships 115 (Lighter-first) vs 130 (Freighter-first). 38,000 was too high (91.9) and 34,000 pushed Lighter-first four ships to 156; 36,000 sits between. - **No base ship can strand on the fixed map** (every destination can reach some discovered place back), so the would-strand tests use the one case that still can: a Freighter with maxed tanks flies Moon→Jupiter (867 ≥ 792) but from Jupiter (667) reaches neither Earth nor the Moon while Mars and Ceres are undiscovered. + +## Step 15.2 + +- **Every time argument that only located planets is removed**, not just those on `locationPosition` and `routeDistance`: `checkRoute`, `tripFuelCost`, `contractTripGameHours`, `priorityWindow`, `wouldStrand` and the web helpers built on them. Keeping unused time parameters would invite callers to think the answer depends on when. +- **`wouldStrand` takes only the ship, the destination and what is discovered**: its origin, cargo mass and departure time existed to compute the arrival time and the planets' positions then. The configured 48-game-hour margin (`noStrandMarginGameHours`, decision 0.5.79) is removed with them; 15.5 adds fuel to the rule. +- **The Market's reach keeps its shape for one step**: `shipReach` now checks each leg once, so "other dates" is always empty; the field and its "Other dates" line go together in 15.8, the step that removes date wording from the client. diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index 4babdf4..4377231 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract, Dispatch, PlayerState } from "@deadhead/schema"; -import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim"; +import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim"; import { expect, type Page } from "@playwright/test"; /** @@ -109,10 +109,9 @@ export function nextMarsWindow(from: number): number { const [earth, mars] = [content.location("earth")!, content.location("mars")!]; const gameDay = (24 * 3_600_000) / content.config.timeCompression; for (let t = from; t < from + 1_500 * gameDay; t += gameDay) { - const g = realToGame(t, content.config); if ( - checkRoute(clipper, earth, mars, g).ok && - !wouldStrand(clipper, earth, mars, 30, g, new Set(["earth", "moon", "mars"]), content) + checkRoute(clipper, earth, mars).ok && + !wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content) ) { // Well inside the window: a few game days in. return t + 3 * gameDay; diff --git a/apps/web/src/components/Goods.tsx b/apps/web/src/components/Goods.tsx index 681cf36..9ced4e9 100644 --- a/apps/web/src/components/Goods.tsx +++ b/apps/web/src/components/Goods.tsx @@ -63,14 +63,14 @@ export function PriorityChip({ windowHours }: { windowHours: number }) { * True when a Priority contract has idle ships at its origin and none of them * can make the window (CR-007). Display only; the sim decides at dispatch. */ -export function tooSlowForAll(state: PlayerState, contract: Contract, now: number): boolean { +export function tooSlowForAll(state: PlayerState, contract: Contract): boolean { if (!contract.priority || contract.deliveryWindowGameHours === null) return false; const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId); if (idle.length === 0) return false; // Effective stats: an engines upgrade can make the window (CR-009). return idle.every( (ship) => - contractTripGameHours(shipStatsOf(ship), contract, now, content) > + contractTripGameHours(shipStatsOf(ship), contract, content) > contract.deliveryWindowGameHours!, ); } diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index 8f47d41..d2b1a7f 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -4,11 +4,9 @@ import { checkRoute, effectiveStats, MS_PER_HOUR, - realToGame, routeDistance, travelTimeGameHours, type EffectiveStats, - type RealTime, type ShipState, } from "@deadhead/sim"; @@ -26,26 +24,21 @@ export function shipModel(id: string): ShipModel { return found; } -/** Route distance if departing at real time `now`, in million km. */ -export function distanceAt(originId: string, destinationId: string, now: number): number { - return routeDistance( - location(originId), - location(destinationId), - realToGame(now, content.config), - ); +/** Route distance in million km; fixed (decision 0.5.93). */ +export function distanceAt(originId: string, destinationId: string): number { + return routeDistance(location(originId), location(destinationId)); } -/** Trip duration in real milliseconds for a ship carrying `massTonnes`, departing at `now`. */ +/** Trip duration in real milliseconds for a ship carrying `massTonnes`. */ export function tripRealMs( model: ShipModel, originId: string, destinationId: string, massTonnes: number, - now: number, ) { const hours = travelTimeGameHours( model, - distanceAt(originId, destinationId, now), + distanceAt(originId, destinationId), massTonnes, content.config, ); @@ -69,28 +62,20 @@ export function simShip(ship: Ship): ShipState { }; } -/** Monthly samples over 16 game years from the epoch: the reach horizon (CR-012). */ -const REACH_SAMPLES = (() => { - const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); - const month = 30.44 * 24 * MS_PER_HOUR; - return Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * month); -})(); const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`; -const everCache = new Map>(); +const legCache = new Map>(); -/** Legs a model flies on at least one sampled date. */ -function everInRange(model: ShipModel): Set { - let legs = everCache.get(model.id); +/** Legs a model can fly (the map is fixed, decision 0.5.93). */ +function legsInRange(model: ShipModel): Set { + let legs = legCache.get(model.id); if (!legs) { legs = new Set(); for (const from of content.locations) { for (const to of content.locations) { - if (from.id !== to.id && REACH_SAMPLES.some((t) => checkRoute(model, from, to, t).ok)) { - legs.add(legKey(from.id, to.id)); - } + if (from.id !== to.id && checkRoute(model, from, to).ok) legs.add(legKey(from.id, to.id)); } } - everCache.set(model.id, legs); + legCache.set(model.id, legs); } return legs; } @@ -105,19 +90,19 @@ export interface ShipReach { } /** - * Where a base model can fly from `originIds` (Market, Step 10L.7): legs in range - * at `now`, legs in range on other dates, and places it can reach only by - * hopping (each leg in range on some date over 16 game years). + * Where a base model can fly from `originIds` (Market, Step 10L.7): legs in + * range from each origin, and places it can reach only by hopping. On the fixed + * map (decision 0.5.93) `otherDates` is always empty; it goes with its wording + * in Step 15.8. */ -export function shipReach(model: ShipModel, originIds: string[], now: RealTime): ShipReach { - const t = realToGame(now, content.config); - const ever = everInRange(model); +export function shipReach(model: ShipModel, originIds: string[]): ShipReach { + const ever = legsInRange(model); const ids = content.locations.map((l) => l.id); const origins = new Set(originIds); const nowLegs = originIds.map((originId) => ({ originId, destinationIds: ids.filter( - (to) => to !== originId && checkRoute(model, location(originId), location(to), t).ok, + (to) => to !== originId && checkRoute(model, location(originId), location(to)).ok, ), })); const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds)); diff --git a/apps/web/src/map/projection.ts b/apps/web/src/map/projection.ts index 6ae03e5..f1702be 100644 --- a/apps/web/src/map/projection.ts +++ b/apps/web/src/map/projection.ts @@ -1,6 +1,6 @@ import type { Location } from "@deadhead/content"; import { content } from "@deadhead/content"; -import { locationPosition, type GameTime, type Position } from "@deadhead/sim"; +import { locationPosition, type Position } from "@deadhead/sim"; /** * Map coordinates: the Sun at (0, 0), the edge of the map at radius 1, y down. @@ -40,12 +40,12 @@ export function toMapPoint(position: Position): MapPoint { * distance from its parent in its true direction, since at map scale it would * overlap it. */ -export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint { +export function locationMapPoint(location: Location): MapPoint { const parentId = location.orbitParent?.locationId; const parent = parentId ? content.location(parentId) : undefined; - if (!parent) return toMapPoint(locationPosition(location, gameTime)); - const earth = locationPosition(parent, gameTime); - const moon = locationPosition(location, gameTime); + if (!parent) return toMapPoint(locationPosition(location)); + const earth = locationPosition(parent); + const moon = locationPosition(location); const dx = moon.x - earth.x; const dy = -(moon.y - earth.y); const length = Math.hypot(dx, dy) || 1; diff --git a/apps/web/src/map/reach.ts b/apps/web/src/map/reach.ts index 0dd0d4b..ec3d691 100644 --- a/apps/web/src/map/reach.ts +++ b/apps/web/src/map/reach.ts @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { PlayerState, Ship } from "@deadhead/schema"; -import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim"; +import { checkRoute, effectiveStats } from "@deadhead/sim"; export interface OriginReach { originId: string; @@ -31,17 +31,15 @@ export function playerOrigins(state: Pick, locationId: string, - now: number, ): OriginReach[] { const target = content.location(locationId); if (!target) return []; - const gameTime = realToGame(now, content.config); return playerOrigins(state) .filter((id) => id !== locationId) .map((originId) => { const origin = content.location(originId)!; const reaches = (stats: Parameters[0]) => - checkRoute(stats, origin, target, gameTime).ok; + checkRoute(stats, origin, target).ok; const models = content.purchasableShipModels.filter(reaches).map((m) => m.name); const upgraded = state.ships .filter((ship: Ship) => { diff --git a/apps/web/src/map/scene.ts b/apps/web/src/map/scene.ts index ccdcd30..52f9b47 100644 --- a/apps/web/src/map/scene.ts +++ b/apps/web/src/map/scene.ts @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Dispatch, PlayerState } from "@deadhead/schema"; -import { dispatchProgress, locationPosition, loanerModel, realToGame } from "@deadhead/sim"; +import { dispatchProgress, locationPosition, loanerModel } from "@deadhead/sim"; import { locationMapPoint, radialScale, type MapPoint } from "./projection"; import { mapStyle } from "./style"; @@ -64,14 +64,8 @@ export function anchorLocation(id: string) { * on the line however the map is scaled. */ export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight { - const from = locationMapPoint( - anchorLocation(dispatch.originId), - realToGame(dispatch.departureAt, content.config), - ); - const to = locationMapPoint( - anchorLocation(dispatch.destinationId), - realToGame(dispatch.arrivalAt, content.config), - ); + const from = locationMapPoint(anchorLocation(dispatch.originId)); + const to = locationMapPoint(anchorLocation(dispatch.destinationId)); const progress = dispatchProgress(dispatch, now); return { dispatchId: dispatch.id, @@ -89,7 +83,6 @@ export function flightMarker(dispatch: Dispatch, now: number, modelId: string): /** Everything the map draws at real time `now`, in map coordinates. */ export function buildScene(state: PlayerState, now: number): Scene { - const gameTime = realToGame(now, content.config); const idleAt = (locationId: string) => state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length + (state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId @@ -98,11 +91,11 @@ export function buildScene(state: PlayerState, now: number): Scene { return { locations: content.locations.map((loc) => { - const sun = locationPosition(loc, gameTime); + const sun = locationPosition(loc); return { id: loc.id, name: loc.name, - point: locationMapPoint(anchorLocation(loc.id), gameTime), + point: locationMapPoint(anchorLocation(loc.id)), parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null, orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)), discovered: state.locations.some((l) => l.id === loc.id && l.discovered), diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 0ada455..4319a8e 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -97,12 +97,7 @@ export function explainProblems( if (contract.massTonnes > model.cargoCapacityTonnes) { problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); } - const route = checkRoute( - model, - location(contract.originId), - location(contract.destinationId), - gameNow, - ); + const route = checkRoute(model, location(contract.originId), location(contract.destinationId)); if (!route.ok) { problems.push( route.reason === "out-of-range" @@ -110,22 +105,14 @@ export function explainProblems( : INELIGIBLE_REASONS["cannot-land"], ); } else if ( - wouldStrand( - model, - location(contract.originId), - location(contract.destinationId), - contract.massTonnes, - gameNow, - discoveredIdsOf(state), - content, - ) + wouldStrand(model, location(contract.destinationId), discoveredIdsOf(state), content) ) { problems.push(strandedText(contract.destinationId)); } // Priority (CR-007): the trip must fit the delivery window. const window = contract.priority ? contract.deliveryWindowGameHours : null; if (window !== null) { - const trip = contractTripGameHours(model, contract, now, content); + const trip = contractTripGameHours(model, contract, content); if (trip > window) { problems.push( `Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`, @@ -199,13 +186,11 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) contract.originId, contract.destinationId, contract.massTonnes, - now, ); const fuelCost = tripFuelCost( model, location(contract.originId), location(contract.destinationId), - gameNow, content.config, ); // Profit-share pay for a hired pilot; the avatar is exempt (CR-010). @@ -263,7 +248,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) priority: contract.priority && contract.deliveryWindowGameHours !== null ? { - tripHours: contractTripGameHours(model, contract, now, content), + tripHours: contractTripGameHours(model, contract, content), windowHours: contract.deliveryWindowGameHours, } : null, diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index d8161af..348a557 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -2,7 +2,6 @@ import { content } from "@deadhead/content"; import type { Dispatch, Ship } from "@deadhead/schema"; import { dispatchProgress, - realToGame, tripFuelCost, validateReposition, type RepositionFailure, @@ -210,7 +209,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id); const discoveredIds = new Set(discovered); - const gameNow = realToGame(now, content.config); const options = discovered .filter((id) => id !== origin) @@ -240,8 +238,8 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v : check.reason === "would-strand" ? strandedText(id) : (DEADHEAD_REASONS[check.reason] ?? check.reason), - fuelCost: tripFuelCost(model, location(origin), location(id), gameNow, content.config), - tripMs: tripRealMs(model, origin, id, 0, now), + fuelCost: tripFuelCost(model, location(origin), location(id), content.config), + tripMs: tripRealMs(model, origin, id, 0), }; }); diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index bb98a89..52a81ee 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -262,7 +262,7 @@ function LocationBoard({ Too heavy )} - {tooSlowForAll(state, contract, now) && ( + {tooSlowForAll(state, contract) && ( Too slow @@ -281,7 +281,7 @@ function LocationBoard({ {contract.massTonnes} t {" "} - · {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))} + · {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx index 7be1707..819b055 100644 --- a/apps/web/src/screens/Locations.tsx +++ b/apps/web/src/screens/Locations.tsx @@ -1,6 +1,6 @@ import { content, type Location } from "@deadhead/content"; import { Link } from "react-router"; -import { useClock } from "../clock/clock"; +import {} from "../clock/clock"; import { cargoName, formatCredits, formatMillionKm } from "../format"; import { distanceAt } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; @@ -21,7 +21,6 @@ const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardene /** Every location; details, contracts and HQ leasing once discovered (Steps 9.9, 10B.9). */ export function Locations() { const state = useLoadedPlayer(); - const now = useClock((s) => s.now); const lease = useGameAction("leaseBay"); return ( @@ -87,7 +86,7 @@ export function Locations() { {location.id === "earth" ? "Home" - : `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`} + : `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 9a516e0..c03c0b7 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -2,7 +2,7 @@ import { content, type Location } from "@deadhead/content"; import { realToGame } from "@deadhead/sim"; import { useState, type ReactNode } from "react"; import { Link } from "react-router"; -import { useClock } from "../clock/clock"; +import {} from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; import { Countdown } from "../components/Countdown"; import { cargoName, formatMillionKm, locationName } from "../format"; @@ -92,12 +92,11 @@ function Panel({ /** A tapped location: details and a link to its contracts once discovered (CR-002). */ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) { const state = useLoadedPlayer(); - const now = useClock((s) => s.now); const location = content.location(locationId)!; const discovered = state.locations.some((l) => l.id === locationId && l.discovered); const hasBay = state.bays.some((b) => b.locationId === locationId); // Per origin the player has a base at (CR-012), e.g. "From Mars: Clipper · From Earth: Freighter". - const reach = discovered ? [] : reachersByOrigin(state, locationId, now); + const reach = discovered ? [] : reachersByOrigin(state, locationId); return (

Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} · diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index eac3b45..83ec553 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -1,7 +1,7 @@ import { content, type ShipModel } from "@deadhead/content"; import { useState } from "react"; import { formatCredits, formatMillionKm, locationName } from "../format"; -import { serverNow } from "../clock/clock"; +import {} from "../clock/clock"; import { shipReach, shipStatsOf } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; @@ -148,7 +148,7 @@ const names = (ids: string[]) => ids.map(locationName).join(", "); function Reach({ model }: { model: ShipModel }) { const state = useLoadedPlayer(); const origins = [...new Set(state.bays.map((b) => b.locationId))]; - const reach = shipReach(model, origins, serverNow()); + const reach = shipReach(model, origins); const parts = [ ...reach.now.map( (leg) => diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 7409e44..bbc53f4 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -2,7 +2,7 @@ import { content } from "@deadhead/content"; import { deliveryXp, loanerModel } from "@deadhead/sim"; import { useQuery } from "@tanstack/react-query"; import { useApi } from "../api/context"; -import { useClock } from "../clock/clock"; +import {} from "../clock/clock"; import { formatCredits, formatDuration, locationName } from "../format"; import { goodName } from "../components/Goods"; import { distanceAt, tripRealMs } from "../game"; @@ -20,7 +20,6 @@ export const INTRO_TIP = "tutorial-intro"; export function TutorialBoard() { const api = useApi(); const state = useLoadedPlayer(); - const now = useClock((s) => s.now); const board = useQuery({ queryKey: TUTORIAL_BOARD_KEY, queryFn: () => api.call("getTutorialBoard", {}), @@ -95,10 +94,9 @@ export function TutorialBoard() { contract.originId, contract.destinationId, contract.massTonnes, - now, ); const xp = deliveryXp( - distanceAt(contract.originId, contract.destinationId, now), + distanceAt(contract.originId, contract.destinationId), contract.tier, content.config, ); diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx index 8d0b72e..701cc0b 100644 --- a/apps/web/test/dispatch-duty.test.tsx +++ b/apps/web/test/dispatch-duty.test.tsx @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; -import { realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; +import { routeDistance, travelTimeGameHours } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,11 +83,7 @@ const state = samplePlayerState({ const tripHours = () => travelTimeGameHours( content.shipModel("courier")!, - routeDistance( - content.location("earth")!, - content.location("moon")!, - realToGame(NOW, content.config), - ), + routeDistance(content.location("earth")!, content.location("moon")!), moonJob.massTonnes, content.config, ); diff --git a/apps/web/test/dispatch-priority.test.tsx b/apps/web/test/dispatch-priority.test.tsx index d6ff24c..17c2af2 100644 --- a/apps/web/test/dispatch-priority.test.tsx +++ b/apps/web/test/dispatch-priority.test.tsx @@ -51,7 +51,7 @@ const rush: Contract = { goodId: "mail", shipper: "Pacific Orbital Logistics", priority: true, - deliveryWindowGameHours: priorityWindow(earth, moon, 6, NOW, content), + deliveryWindowGameHours: priorityWindow(earth, moon, 6, content), tier: 0, massTonnes: 6, payout: 1_500, @@ -62,8 +62,7 @@ const rush: Contract = { firstContact: false, }; -const trip = (modelId: string) => - contractTripGameHours(content.shipModel(modelId)!, rush, NOW, content); +const trip = (modelId: string) => contractTripGameHours(content.shipModel(modelId)!, rush, content); async function openSheet() { renderApp( diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index f1e779e..ec94278 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -116,17 +116,11 @@ describe("dispatch sheet", () => { const model = content.shipModel("freighter")!; const earth = content.location("earth")!; const mars = content.location("mars")!; - const gameNow = realToGame(NOW, content.config); const tripMs = - (travelTimeGameHours( - model, - routeDistance(earth, mars, gameNow), - marsJob.massTonnes, - content.config, - ) * + (travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) * MS_PER_HOUR) / content.config.timeCompression; - const fuel = tripFuelCost(model, earth, mars, gameNow, content.config); + const fuel = tripFuelCost(model, earth, mars, content.config); const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config); expect(within(freighter).getByRole("radio")).toBeEnabled(); diff --git a/apps/web/test/effective-stats-ui.test.tsx b/apps/web/test/effective-stats-ui.test.tsx index 9151c50..f015d11 100644 --- a/apps/web/test/effective-stats-ui.test.tsx +++ b/apps/web/test/effective-stats-ui.test.tsx @@ -94,11 +94,7 @@ describe("effective stats in the client (Step 10I.7)", () => { }); it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => { - const distance = routeDistance( - content.location("earth")!, - content.location("mars")!, - realToGame(NOW, content.config), - ); + const distance = routeDistance(content.location("earth")!, content.location("mars")!); // Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map. expect(distance).toBeGreaterThan(145); expect(distance).toBeLessThan(215); diff --git a/apps/web/test/map-locked.test.tsx b/apps/web/test/map-locked.test.tsx index 899d673..bdc1d84 100644 --- a/apps/web/test/map-locked.test.tsx +++ b/apps/web/test/map-locked.test.tsx @@ -1,5 +1,5 @@ import { content } from "@deadhead/content"; -import { checkRoute, realToGame } from "@deadhead/sim"; +import { checkRoute } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -53,11 +53,8 @@ describe("whole map from the start", () => { const panel = screen.getByRole("dialog", { name: "Mars" }); expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock."); - const gameTime = realToGame(NOW, content.config); const expected = content.purchasableShipModels - .filter( - (m) => checkRoute(m, content.location("earth")!, content.location("mars")!, gameTime).ok, - ) + .filter((m) => checkRoute(m, content.location("earth")!, content.location("mars")!).ok) .map((m) => m.name); expect(expected.length).toBeGreaterThan(0); expect(expected).not.toContain("Courier"); diff --git a/apps/web/test/map-projection.test.ts b/apps/web/test/map-projection.test.ts index 90e54e8..a17c01e 100644 --- a/apps/web/test/map-projection.test.ts +++ b/apps/web/test/map-projection.test.ts @@ -28,13 +28,11 @@ describe("radial scale", () => { describe("map fits a phone", () => { // A 375 px wide phone, minus header and tab bar. const viewport = { width: 375, height: 690 }; - const start = Date.parse("2100-01-01T00:00:00Z"); it("shows all five locations on screen at default zoom, across 12 years", () => { for (let month = 0; month < 12 * 12; month++) { - const gameTime = start + month * 30 * 86_400_000; for (const location of content.locations) { - const p = toScreen(locationMapPoint(location, gameTime), viewport, DEFAULT_CAMERA); + const p = toScreen(locationMapPoint(location), viewport, DEFAULT_CAMERA); expect(p.x).toBeGreaterThanOrEqual(0); expect(p.x).toBeLessThanOrEqual(viewport.width); expect(p.y).toBeGreaterThanOrEqual(0); @@ -44,8 +42,8 @@ describe("map fits a phone", () => { }); it("draws the Moon apart from Earth", () => { - const earth = locationMapPoint(content.location("earth")!, start); - const moon = locationMapPoint(content.location("moon")!, start); + const earth = locationMapPoint(content.location("earth")!); + const moon = locationMapPoint(content.location("moon")!); const a = toScreen(earth, viewport, DEFAULT_CAMERA); const b = toScreen(moon, viewport, DEFAULT_CAMERA); expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5); diff --git a/apps/web/test/map-reach-origins.test.tsx b/apps/web/test/map-reach-origins.test.tsx index d773d13..f06d4a8 100644 --- a/apps/web/test/map-reach-origins.test.tsx +++ b/apps/web/test/map-reach-origins.test.tsx @@ -16,7 +16,7 @@ const [mars, ceres] = [content.location("mars")!, content.location("ceres-statio const NOW = (() => { for (let i = 0; i <= 16 * 12; i++) { const t = gameStart + i * MONTH; - if (checkRoute(clipper, mars, ceres, t).ok) return gameToReal(t, content.config); + if (checkRoute(clipper, mars, ceres).ok) return gameToReal(t, content.config); } throw new Error("no such date"); })(); diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index d588ff7..2afd208 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema"; -import { pilotShare, realToGame, tripFuelCost } from "@deadhead/sim"; +import { pilotShare, tripFuelCost } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -110,7 +110,6 @@ describe("pilot share in the client (Step 10J.6)", () => { content.shipModel("courier")!, content.location("earth")!, content.location("moon")!, - realToGame(NOW, content.config), content.config, ); const share = pilotShare(job.payout, fuel, content.config); diff --git a/apps/web/test/stranding-reason.test.tsx b/apps/web/test/stranding-reason.test.tsx index 455e38d..d2e76c4 100644 --- a/apps/web/test/stranding-reason.test.tsx +++ b/apps/web/test/stranding-reason.test.tsx @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract, PlayerState } from "@deadhead/schema"; -import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim"; +import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,10 +17,9 @@ const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform // The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from // Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered. -const gameNow = realToGame(NOW, content.config); if ( - !checkRoute(freighter, moon, jupiter, gameNow).ok || - !wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content) + !checkRoute(freighter, moon, jupiter).ok || + !wouldStrand(freighter, jupiter, discovered, content) ) { throw new Error("the scenario no longer strands"); } diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 6ce1849..50195f9 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -1,12 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract } from "@deadhead/schema"; -import { - deliveryXp, - MS_PER_HOUR, - realToGame, - routeDistance, - travelTimeGameHours, -} from "@deadhead/sim"; +import { deliveryXp, MS_PER_HOUR, routeDistance, travelTimeGameHours } from "@deadhead/sim"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,7 +17,6 @@ function expected(contract: Contract) { const distance = routeDistance( content.location(contract.originId)!, content.location(contract.destinationId)!, - realToGame(NOW, content.config), ); const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config); return { diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ecd1c97..5991cdb 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -371,7 +371,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as "CR-014 … Phase 14"). New decisions recorded as plan §0.5.93–0.5.99. User decision: a leg's fuel follows the economic-distance curve, like pay. - [x] **Step 15.1 — Content: fixed positions.** Locations lose `body` and gain `position` (`{ radiusMillionKm, angleDegrees }`): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (`offsetPosition`), recorded as `orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }`. GameConfig's epoch keeps only a displayed start date (`gameStart`, was `ephemerisDate`; same value). `astronomy-engine` is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's `locationPosition` already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (`bodyPosition` is gone; the Moon is placed from `orbitParent`). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: `packages/content/test/fixed-map.test.ts` (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no `astronomy-engine` in the lockfile). Date-sampled tests became yes/no: `reach-table.test.ts` is now CR-015 §4, one test per cell; `courier-mars`, `eligibility`, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8). -- [ ] Step 15.2 — Sim: static positions and distances +- [x] **Step 15.2 — Sim: static positions and distances.** No time arguments left where a time only fed a position: `locationPosition(location)`, `routeDistance(from, to)`, `checkRoute(ship, from, to)`, `tripFuelCost(model, origin, destination, config)`, `contractTripGameHours(model, contract, content)`, `priorityWindow(origin, destination, mass, content)`, and `wouldStrand(model, destination, discoveredIds, content)` (it had taken cargo mass and departure only to find where planets would be on arrival). `dispatchPosition` runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's `noStrandMarginGameHours` is gone (nothing moves in 48 game hours now). Web: `distanceAt`, `tripRealMs`, `locationMapPoint`, `reachersByOrigin`, `tooSlowForAll` and `shipReach` lose their time argument; `shipReach` checks each leg once instead of sampling 16 game years (its `otherDates` is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: `distance.test.ts` per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), `positions.test.ts` ("never moves"), new `static-routes.test.ts` (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); `no-strand.test.ts` now checks that no base ship is ever blocked on a leg it can fly; `range-from` and `upgrade-caps` lose their date loops. - [ ] Step 15.3 — Content: fuel - [ ] Step 15.4 — Sim: tank, burn, and refuel - [ ] Step 15.5 — Sim: stranding and emergency fuel diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 727cbd8..9fb0835 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -30,8 +30,6 @@ export const gameConfig: GameConfig = { savedDispatchRetentionRealHours: 168, // Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84). economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 }, - // A ship must be able to fly out of its destination two game days after arriving (CR-012). - noStrandMarginGameHours: 48, // Ship upgrades (CR-009): level 1, 2, 3. upgradeLevels: [ { costPercentOfPrice: 10, shopRealMinutes: 10 }, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 10f0cb8..7d03ef1 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -246,8 +246,6 @@ export const gameConfigSchema = z referenceMillionKm: z.number().positive(), exponent: z.number().positive().max(1), }), - /** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */ - noStrandMarginGameHours: z.number().nonnegative(), /** * Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and * real minutes in the shop (CR-009). diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index a5e0c1b..efb6ce0 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -80,7 +80,6 @@ const validGameConfig = { contractBoardSize: 8, tutorialBoardSize: 50, contractBoardPeriodRealMinutes: 60, - noStrandMarginGameHours: 48, dutyLimitGameHours: 12, longHaulDutyBonusGameHours: 2, restGameHours: 60, diff --git a/packages/engine/test/ported/contract-dispatch.test.ts b/packages/engine/test/ported/contract-dispatch.test.ts index ccdf419..3e8ff31 100644 --- a/packages/engine/test/ported/contract-dispatch.test.ts +++ b/packages/engine/test/ported/contract-dispatch.test.ts @@ -213,7 +213,7 @@ describe("Priority contracts (CR-007)", () => { async function priorityJob(o: Owner, id: string, slackHours: number) { const { clock } = testApp(); const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true }); - const trip = contractTripGameHours(courier, base, clock.now, content); + const trip = contractTripGameHours(courier, base, content); const contract = { ...base, deliveryWindowGameHours: trip + slackHours }; await putBoard(testApp().app, [contract], clock.now); return contract; diff --git a/packages/engine/test/ported/lighter-clipper.test.ts b/packages/engine/test/ported/lighter-clipper.test.ts index 7cd72db..70909fd 100644 --- a/packages/engine/test/ported/lighter-clipper.test.ts +++ b/packages/engine/test/ported/lighter-clipper.test.ts @@ -1,7 +1,7 @@ // Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine. import { content } from "@deadhead/content"; import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema"; -import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim"; +import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { TEST_NOW, useTestApp, call, getState, edit, setCredits, signUpOwner } from "../support"; @@ -27,7 +27,7 @@ describe("Lighter and Clipper (Step 10L.6)", () => { } it("refuses a deadhead that would strand the ship, changing nothing", async () => { - const { app, clock } = testApp(); + const { app } = testApp(); const owner = await signUpOwner(app); await setCredits(app, 100_000); // A Freighter with maxed tanks at the Moon, with Jupiter discovered but not @@ -54,10 +54,9 @@ describe("Lighter and Clipper (Step 10L.6)", () => { content.location("moon")!, content.location("jupiter-cloud-platform")!, ]; - const now = realToGame(clock.now, content.config); - expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true); + expect(checkRoute(freighter, moon, jupiter).ok).toBe(true); const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]); - expect(wouldStrand(freighter, moon, jupiter, 0, now, discovered, content)).toBe(true); + expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true); const before = await getState(app, owner); const response = await call(app, owner, "POST", "/api/dispatches", { diff --git a/packages/engine/test/ported/upgrades.test.ts b/packages/engine/test/ported/upgrades.test.ts index c663dac..572fc19 100644 --- a/packages/engine/test/ported/upgrades.test.ts +++ b/packages/engine/test/ported/upgrades.test.ts @@ -9,7 +9,6 @@ import { import { effectiveStats, MS_PER_HOUR, - realToGame, routeDistance, travelTimeGameHours, upgradeCost, @@ -93,11 +92,7 @@ describe("starting an upgrade (Step 10I.5)", () => { const flown = await dispatch(); expect(flown.statusCode).toBe(200); const { dispatch: trip } = dispatchResponseSchema.parse(flown.json()); - const distance = routeDistance( - content.location("earth")!, - content.location("moon")!, - realToGame(until, content.config), - ); + const distance = routeDistance(content.location("earth")!, content.location("moon")!); const hours = travelTimeGameHours( effectiveStats(freighterModel, { engines: 1 }, content), distance, diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index 4fc65d0..913b792 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -27,7 +27,6 @@ import { nextUpgradeLevel, pilotLevel, pilotShare, - realToGame, resolveDispatch, restEndsAt, skillPointsAvailable, @@ -244,17 +243,10 @@ export function simulate(options: SimulationOptions): SimulationResult { /** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */ function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number { - const departure = realToGame(now, config); - const fuel = tripFuelCost( - stats, - loc(contract.originId), - loc(contract.destinationId), - departure, - config, - ); + const fuel = tripFuelCost(stats, loc(contract.originId), loc(contract.destinationId), config); const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config); const tripMs = - (contractTripGameHours(stats, contract, now, content) * MS_PER_HOUR) / config.timeCompression; + (contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression; return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE); } @@ -594,9 +586,7 @@ export function simulate(options: SimulationOptions): SimulationResult { const target = deadheads .map((to) => { const best = board(to) - .filter( - (c) => checkRoute(stats, loc(to), loc(c.destinationId), realToGame(now, config)).ok, - ) + .filter((c) => checkRoute(stats, loc(to), loc(c.destinationId)).ok) .map((c) => c.payout) .sort((a, b) => b - a)[0]; return { to, best: best ?? 0 }; @@ -643,13 +633,8 @@ export function simulate(options: SimulationOptions): SimulationResult { discovered.has(ship.locationId) && board(ship.locationId).some( (c) => - tripFuelCost( - model(ship.modelId), - loc(c.originId), - loc(c.destinationId), - realToGame(now, config), - config, - ) <= credits, + tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <= + credits, ) ); }); diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index 6da8c12..4c8e574 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -8,7 +8,7 @@ import type { Skill, Upgrade, } from "@deadhead/content"; -import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock"; +import { MS_PER_MINUTE, type RealTime } from "./clock"; import { contractPayout, type Contract } from "./contract"; import { routeDistance } from "./distance"; import { checkRoute } from "./eligibility"; @@ -125,7 +125,6 @@ function fitJob( offeredAt: RealTime, content: SimContent, ) { - const gameTime = realToGame(offeredAt, content.config); const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min); if (able.length === 0) return null; const start = rng.int(0, able.length - 1); @@ -133,8 +132,8 @@ function fitJob( const { model, skills } = able[(start + k) % able.length]!; const reachable = discovered.filter( (d) => - checkRoute(model, origin, d, gameTime).ok && - requiredLongHaulLevel(routeDistance(origin, d, gameTime), content.config) <= + checkRoute(model, origin, d).ok && + requiredLongHaulLevel(routeDistance(origin, d), content.config) <= skillLevel(skills, LONG_HAUL_SKILL_ID), ); if (reachable.length === 0) continue; @@ -266,13 +265,12 @@ export function priorityWindow( origin: Location, destination: Location, massTonnes: number, - offeredAt: RealTime, content: SimContent, ): number { const { config } = content; const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId); if (!reference) throw new Error(`Unknown ship model ${config.priorityReferenceShipModelId}`); - const distance = routeDistance(origin, destination, realToGame(offeredAt, config)); + const distance = routeDistance(origin, destination); return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor; } @@ -294,19 +292,9 @@ function rollPriority( ): { priority: boolean; deliveryWindowGameHours: number | null } { const none = { priority: false, deliveryWindowGameHours: null }; if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none; - const window = priorityWindow( - args.origin, - args.destination, - args.massTonnes, - args.offeredAt, - content, - ); + const window = priorityWindow(args.origin, args.destination, args.massTonnes, content); if (args.model) { - const distance = routeDistance( - args.origin, - args.destination, - realToGame(args.offeredAt, content.config), - ); + const distance = routeDistance(args.origin, args.destination); const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config); if (trip > window) return none; } @@ -358,7 +346,7 @@ export function buildContract( const { config } = content; const cargoType = cargoClassOf(args.good, content); const offeredAt = args.periodIndex * boardPeriodMs(config); - const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config)); + const distance = routeDistance(args.origin, args.destination); return { id: args.id, originId: args.origin.id, diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 74e7dc4..9a5b799 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -1,6 +1,6 @@ import type { GameConfig, Location, ShipModel } from "@deadhead/content"; import type { SimContent } from "./board"; -import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; +import { MS_PER_HOUR, type RealTime } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { isResting } from "./duty"; @@ -88,56 +88,47 @@ export function findLocation(id: string, content: SimContent): Location { return location; } -/** Fuel charged for a trip, in whole credits, using the distance at departure. */ +/** Fuel charged for a trip, in whole credits. */ export function tripFuelCost( model: ShipModel, origin: Location, destination: Location, - departure: GameTime, config: GameConfig, ): number { - return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure), config)); + return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config)); } const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); -/** A ship's trip time for a contract, departing now: cruise, overhead and handling (game hours). */ +/** A ship's trip time for a contract: cruise, overhead and handling (game hours). */ export function contractTripGameHours( model: ShipModel, contract: Contract, - now: RealTime, content: SimContent, ): number { const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); - const distance = routeDistance(origin, destination, realToGame(now, content.config)); + const distance = routeDistance(origin, destination); return travelTimeGameHours(model, distance, contract.massTonnes, content.config); } /** - * The no-strand rule (decision 0.5.79): true when, at arrival plus the configured - * margin, the ship could not fly from the destination to any other discovered - * location. `discoveredIds` absent means every location counts. + * The no-strand rule (decision 0.5.79): true when the ship could not fly from + * the destination to any other discovered location. `discoveredIds` absent + * means every location counts. The map is fixed (0.5.93), so arrival time + * doesn't matter. */ export function wouldStrand( - /** Effective stats, or a bare model (handling falls back to the game-wide base). */ model: ShipModel, - origin: Location, destination: Location, - massTonnes: number, - departure: GameTime, discoveredIds: ReadonlySet | undefined, content: SimContent, ): boolean { - const { config } = content; - const distance = routeDistance(origin, destination, departure); - const tripHours = travelTimeGameHours(model, distance, massTonnes, config); - const at = departure + (tripHours + config.noStrandMarginGameHours) * MS_PER_HOUR; return !content.locations.some( (next) => next.id !== destination.id && (discoveredIds?.has(next.id) ?? true) && - checkRoute(model, destination, next, at).ok, + checkRoute(model, destination, next).ok, ); } @@ -147,7 +138,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent const model = shipStats(ship, content); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); - const departure = realToGame(now, content.config); if (ship.inFlight) return fail("ship-not-idle"); if (isInShop(ship, now)) return fail("ship-in-shop"); @@ -161,19 +151,9 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent } if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity"); - const route = checkRoute(model, origin, destination, departure); + const route = checkRoute(model, origin, destination); if (!route.ok) return fail(route.reason); - if ( - wouldStrand( - model, - origin, - destination, - contract.massTonnes, - departure, - proposal.discoveredIds, - content, - ) - ) { + if (wouldStrand(model, destination, proposal.discoveredIds, content)) { return fail("would-strand"); } @@ -181,7 +161,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent if ( contract.priority && contract.deliveryWindowGameHours !== null && - contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours + contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours ) { return fail("too-slow"); } @@ -192,7 +172,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent if (now >= contract.expiresAt) return fail("contract-expired"); if (!tutorial) { - if (credits < tripFuelCost(model, origin, destination, departure, content.config)) + if (credits < tripFuelCost(model, origin, destination, content.config)) return fail("insufficient-credits"); } return { ok: true }; @@ -247,8 +227,7 @@ function buildRecord( const model = shipStats(ship, content); const origin = findLocation(args.originId, content); const destination = findLocation(args.destinationId, content); - const departure = realToGame(now, config); - const distance = routeDistance(origin, destination, departure); + const distance = routeDistance(origin, destination); const mass = contract?.massTonnes ?? 0; const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR; @@ -263,7 +242,7 @@ function buildRecord( departureAt: now, arrivalAt: now + Math.round(travelGameMs / config.timeCompression), distanceMillionKm: distance, - fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure, content.config), + fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, content.config), expectedPayout: contract?.payout ?? 0, seed: deriveSeed(worldSeed, `dispatch:${id}`), }; @@ -329,14 +308,13 @@ export function validateReposition( const model = shipStats(ship, content); const origin = findLocation(ship.locationId, content); const destination = findLocation(destinationId, content); - const departure = realToGame(now, content.config); - const route = checkRoute(model, origin, destination, departure); + const route = checkRoute(model, origin, destination); if (!route.ok) return failWith(route.reason); - if (wouldStrand(model, origin, destination, 0, departure, discoveredIds, content)) { + if (wouldStrand(model, destination, discoveredIds, content)) { return failWith("would-strand"); } - if (credits < tripFuelCost(model, origin, destination, departure, content.config)) { + if (credits < tripFuelCost(model, origin, destination, content.config)) { return failWith("insufficient-credits"); } return { ok: true }; diff --git a/packages/sim/src/distance.ts b/packages/sim/src/distance.ts index cf00295..7c8e63a 100644 --- a/packages/sim/src/distance.ts +++ b/packages/sim/src/distance.ts @@ -1,10 +1,9 @@ import type { Location } from "@deadhead/content"; -import type { GameTime } from "./clock"; import { locationPosition } from "./positions"; -/** Straight-line distance between two locations at a game time, in million km. */ -export function routeDistance(from: Location, to: Location, gameTime: GameTime): number { - const a = locationPosition(from, gameTime); - const b = locationPosition(to, gameTime); +/** Straight-line distance between two locations, in million km; fixed (decision 0.5.93). */ +export function routeDistance(from: Location, to: Location): number { + const a = locationPosition(from); + const b = locationPosition(to); return Math.hypot(b.x - a.x, b.y - a.y); } diff --git a/packages/sim/src/eligibility.ts b/packages/sim/src/eligibility.ts index 1d68b17..18ebbf9 100644 --- a/packages/sim/src/eligibility.ts +++ b/packages/sim/src/eligibility.ts @@ -1,5 +1,4 @@ import type { LandingClass, Location, ShipModel } from "@deadhead/content"; -import type { GameTime } from "./clock"; import { routeDistance } from "./distance"; const LANDING_RANK: Record = { vacuum: 0, atmospheric: 1, hardened: 2 }; @@ -20,17 +19,12 @@ export function rangeFrom(ship: ShipModel, from: Location): number { } /** - * Whether a ship can fly from one location to another at a game time, or why - * not. Range is per leg, from the origin, at departure (decision 0.5.77). + * Whether a ship can fly from one location to another, or why not. Range is + * per leg, from the origin (decision 0.5.77); the map is fixed (0.5.93). */ -export function checkRoute( - ship: ShipModel, - from: Location, - to: Location, - gameTime: GameTime, -): RouteCheck { +export function checkRoute(ship: ShipModel, from: Location, to: Location): RouteCheck { if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" }; - if (routeDistance(from, to, gameTime) > rangeFrom(ship, from)) { + if (routeDistance(from, to) > rangeFrom(ship, from)) { return { ok: false, reason: "out-of-range" }; } return { ok: true }; diff --git a/packages/sim/src/flight.ts b/packages/sim/src/flight.ts index e35d588..82a078a 100644 --- a/packages/sim/src/flight.ts +++ b/packages/sim/src/flight.ts @@ -1,5 +1,5 @@ import type { SimContent } from "./board"; -import { type RealTime, realToGame } from "./clock"; +import type { RealTime } from "./clock"; import { type DispatchRecord, findLocation } from "./dispatch"; import { locationPosition, type Position } from "./positions"; @@ -10,24 +10,14 @@ export function dispatchProgress(record: DispatchRecord, now: RealTime): number return Math.min(1, Math.max(0, (now - record.departureAt) / duration)); } -/** - * Ship position at a real time: a straight line from the origin's position at - * departure to the destination's position at arrival. - */ +/** Ship position at a real time: along the straight line from origin to destination. */ export function dispatchPosition( record: DispatchRecord, now: RealTime, content: SimContent, ): Position { - const { config } = content; - const start = locationPosition( - findLocation(record.originId, content), - realToGame(record.departureAt, config), - ); - const end = locationPosition( - findLocation(record.destinationId, content), - realToGame(record.arrivalAt, config), - ); + const start = locationPosition(findLocation(record.originId, content)); + const end = locationPosition(findLocation(record.destinationId, content)); const t = dispatchProgress(record, now); return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t }; } diff --git a/packages/sim/src/positions.ts b/packages/sim/src/positions.ts index d1e70df..89cdd84 100644 --- a/packages/sim/src/positions.ts +++ b/packages/sim/src/positions.ts @@ -1,5 +1,4 @@ import type { FixedPosition, Location } from "@deadhead/content"; -import type { GameTime } from "./clock"; /** A heliocentric point in the plane of the map, in million km. */ export interface Position { @@ -16,10 +15,7 @@ export function fixedPoint(position: FixedPosition): Position { }; } -/** - * A location's position in million km from the Sun. The map is fixed - * (decision 0.5.93); the game time is ignored and goes away in Step 15.2. - */ -export function locationPosition(location: Location, _gameTime?: GameTime): Position { +/** A location's position in million km from the Sun; it never moves (decision 0.5.93). */ +export function locationPosition(location: Location): Position { return fixedPoint(location.position); } diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index fd47550..c16b90d 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { simulate, type Milestones, type SimulationResult } from "../balance/simulate"; import { content } from "@deadhead/content"; -import { MS_PER_HOUR, realToGame, tripFuelCost } from "../src/index"; +import { MS_PER_HOUR, tripFuelCost } from "../src/index"; /** * Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on @@ -75,8 +75,7 @@ describe("balance simulation (Step 12.1)", () => { // Step 12.2: the tutorial share and starting credits as actually earned. const courier = content.shipModel("courier")!; const [earth, moon] = [content.location("earth")!, content.location("moon")!]; - const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); - const fuel = tripFuelCost(courier, earth, moon, start, content.config); + const fuel = tripFuelCost(courier, earth, moon, content.config); for (const result of lighterFirst) { expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal); expect(result.tutorialShareEarned).toBeGreaterThan(0); diff --git a/packages/sim/test/board-fleet.test.ts b/packages/sim/test/board-fleet.test.ts index f6c1d85..fa49bb1 100644 --- a/packages/sim/test/board-fleet.test.ts +++ b/packages/sim/test/board-fleet.test.ts @@ -6,7 +6,6 @@ import { checkRoute, contractTripGameHours, generateContractBoard, - realToGame, type BoardInput, type Contract, type FleetMember, @@ -21,16 +20,14 @@ const bands = config.contractMassBands; /** Whether this crew can fly the contract (the sim's own route and pilot rules). */ function fits(contract: Contract, member: FleetMember): boolean { const model = content.shipModel(member.modelId)!; - const gameTime = realToGame(contract.offeredAt, config); return ( !contract.firstContact && contract.massTonnes <= model.cargoCapacityTonnes && - checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok && + checkRoute(model, earth, content.location(contract.destinationId)!).ok && checkPilotEligibility(member.skills, contract).ok && // Priority (CR-007): the ship must make the delivery window. (!contract.priority || - contractTripGameHours(model, contract, contract.offeredAt, content) <= - contract.deliveryWindowGameHours!) + contractTripGameHours(model, contract, content) <= contract.deliveryWindowGameHours!) ); } diff --git a/packages/sim/test/board-goods.test.ts b/packages/sim/test/board-goods.test.ts index f2012e1..1a02d67 100644 --- a/packages/sim/test/board-goods.test.ts +++ b/packages/sim/test/board-goods.test.ts @@ -107,7 +107,7 @@ describe("goods in board generation (CR-007)", () => { priority++; expect(good.priorityEligible).toBe(true); expect(c.deliveryWindowGameHours).toBeCloseTo( - priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, c.offeredAt, content), + priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, content), ); } } diff --git a/packages/sim/test/courier-mars.test.ts b/packages/sim/test/courier-mars.test.ts index c1f263d..37cac2b 100644 --- a/packages/sim/test/courier-mars.test.ts +++ b/packages/sim/test/courier-mars.test.ts @@ -7,7 +7,6 @@ const tanks = content.upgrade("fuel-tanks")!; const earth = content.location("earth")!; const moon = content.location("moon")!; const mars = content.location("mars")!; -const now = Date.parse("2100-01-01T00:00:00Z"); /** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */ const withTanks = (level: number): ShipModel => ({ @@ -19,13 +18,13 @@ const withTanks = (level: number): ShipModel => ({ // Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map. describe("Courier to Mars (decision 0.5.68)", () => { it("reaches Mars from Earth with maxed fuel tanks", () => { - expect(checkRoute(withTanks(3), earth, mars, now)).toEqual({ ok: true }); + expect(checkRoute(withTanks(3), earth, mars)).toEqual({ ok: true }); }); it("can't reach Mars from Earth or the Moon with fuel tanks at level 2 or less", () => { for (const level of [0, 1, 2]) { - expect(checkRoute(withTanks(level), earth, mars, now).ok, `level ${level}`).toBe(false); - expect(checkRoute(withTanks(level), moon, mars, now).ok, `level ${level}`).toBe(false); + expect(checkRoute(withTanks(level), earth, mars).ok, `level ${level}`).toBe(false); + expect(checkRoute(withTanks(level), moon, mars).ok, `level ${level}`).toBe(false); } }); }); diff --git a/packages/sim/test/distance.test.ts b/packages/sim/test/distance.test.ts index e577ab2..afca4ff 100644 --- a/packages/sim/test/distance.test.ts +++ b/packages/sim/test/distance.test.ts @@ -1,32 +1,29 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { routeDistance } from "../src/index"; +import { locationPosition, routeDistance } from "../src/index"; const earth = content.location("earth")!; const moon = content.location("moon")!; -const mars = content.location("mars")!; - -const start = Date.parse("2100-01-01T00:00:00Z"); -const MS_PER_DAY = 86_400_000; -/** Game times sampled every ~30 days across two years. */ -const monthly = Array.from({ length: 25 }, (_, i) => start + i * 30 * MS_PER_DAY); +// Step 15.2 (decision 0.5.93): positions and distances take no time; they never change. describe("route distance", () => { - it("keeps Earth→Moon under 1 million km at any time", () => { - for (const t of monthly) { - expect(routeDistance(earth, moon, t)).toBeLessThan(1); - } - }); - - it("gives the same distance at every time (decision 0.5.93)", () => { - const first = routeDistance(earth, mars, monthly[0]!); - for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first); + it("keeps Earth→Moon under 1 million km", () => { + expect(routeDistance(earth, moon)).toBeLessThan(1); }); it("is symmetric", () => { for (const a of content.locations) { for (const b of content.locations) { - expect(routeDistance(a, b, start)).toBe(routeDistance(b, a, start)); + expect(routeDistance(a, b)).toBe(routeDistance(b, a)); + } + } + }); + + it("returns the same value for the same inputs, every time", () => { + for (const a of content.locations) { + expect(locationPosition(a)).toEqual(locationPosition(a)); + for (const b of content.locations) { + expect(routeDistance(a, b)).toBe(routeDistance(a, b)); } } }); diff --git a/packages/sim/test/effective-stats.test.ts b/packages/sim/test/effective-stats.test.ts index f8899ca..372dfe7 100644 --- a/packages/sim/test/effective-stats.test.ts +++ b/packages/sim/test/effective-stats.test.ts @@ -2,7 +2,6 @@ import { content, SHIP_STATS } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { effectiveStats, - realToGame, routeDistance, shipStats, travelTimeGameHours, @@ -86,11 +85,7 @@ describe("effective stats (Step 10I.2)", () => { }); it("lets an engines upgrade make a Priority window that was too short", () => { - const distance = routeDistance( - content.location("earth")!, - content.location("moon")!, - realToGame(now, content.config), - ); + const distance = routeDistance(content.location("earth")!, content.location("moon")!); const hours = (stats: typeof courier) => travelTimeGameHours(stats, distance, contract.massTonnes, content.config); const slow = hours(effectiveStats(courier, {}, content)); diff --git a/packages/sim/test/eligibility.test.ts b/packages/sim/test/eligibility.test.ts index ef45ee4..dcd15b4 100644 --- a/packages/sim/test/eligibility.test.ts +++ b/packages/sim/test/eligibility.test.ts @@ -15,38 +15,37 @@ const bulkHauler = content.shipModel("bulk-hauler")!; const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" }; // The map is fixed (decision 0.5.93), so any time gives the same answer. -const now = Date.parse("2100-01-01T00:00:00Z"); const outOfRange = { ok: false, reason: "out-of-range" }; // Origins are named in every test (decision 0.5.77: range is per leg, from its origin). describe("route eligibility", () => { it("lets a Courier fly Earth→Moon and Moon→Earth", () => { - expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true }); - expect(checkRoute(courier, moon, earth, now)).toEqual({ ok: true }); + expect(checkRoute(courier, earth, moon)).toEqual({ ok: true }); + expect(checkRoute(courier, moon, earth)).toEqual({ ok: true }); }); it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => { for (const to of [mars, ceres, jupiter]) { - expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange); + expect(checkRoute(courier, earth, to)).toEqual(outOfRange); } }); it("rejects a vacuum-only ship at Earth because it cannot land", () => { - expect(checkRoute(vacuumShip, moon, earth, now)).toEqual({ + expect(checkRoute(vacuumShip, moon, earth)).toEqual({ ok: false, reason: "cannot-land", }); - expect(checkRoute(vacuumShip, earth, moon, now)).toEqual({ ok: true }); + expect(checkRoute(vacuumShip, earth, moon)).toEqual({ ok: true }); }); it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => { - expect(checkRoute(freighter, earth, mars, now)).toEqual({ ok: true }); - expect(checkRoute(freighter, earth, ceres, now)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, mars)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, ceres)).toEqual({ ok: true }); }); it("lets only the Bulk Hauler reach Jupiter from Earth", () => { - expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true }); - expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange); - expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange); + expect(checkRoute(bulkHauler, earth, jupiter)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, jupiter)).toEqual(outOfRange); + expect(checkRoute(courier, earth, jupiter)).toEqual(outOfRange); }); }); diff --git a/packages/sim/test/flight.test.ts b/packages/sim/test/flight.test.ts index d1ca55c..392b4c2 100644 --- a/packages/sim/test/flight.test.ts +++ b/packages/sim/test/flight.test.ts @@ -5,7 +5,6 @@ import { dispatchPosition, dispatchProgress, locationPosition, - realToGame, } from "../src/index"; const record = createRepositionRecord( @@ -42,14 +41,8 @@ describe("in-flight progress", () => { }); describe("in-flight position", () => { - const origin = locationPosition( - content.location("earth")!, - realToGame(record.departureAt, content.config), - ); - const destination = locationPosition( - content.location("moon")!, - realToGame(record.arrivalAt, content.config), - ); + const origin = locationPosition(content.location("earth")!); + const destination = locationPosition(content.location("moon")!); it("starts at the origin's position at departure", () => { expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin); diff --git a/packages/sim/test/fuel.test.ts b/packages/sim/test/fuel.test.ts index 22b95ed..acad4c8 100644 --- a/packages/sim/test/fuel.test.ts +++ b/packages/sim/test/fuel.test.ts @@ -28,8 +28,7 @@ describe("economic distance (decision 0.5.84)", () => { describe("fuel cost", () => { it("costs more Earth→Moon than Moon→Earth for the same ship", () => { - const t = Date.parse("2100-01-01T00:00:00Z"); - const distance = routeDistance(earth, moon, t); + const distance = routeDistance(earth, moon); expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan( fuelCost(courier, moon, distance, config), ); diff --git a/packages/sim/test/no-strand.test.ts b/packages/sim/test/no-strand.test.ts index 09c317e..fd54677 100644 --- a/packages/sim/test/no-strand.test.ts +++ b/packages/sim/test/no-strand.test.ts @@ -1,12 +1,8 @@ -import { content, type Location, type ShipModel } from "@deadhead/content"; +import { content, type Location } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { checkRoute, effectiveStats, - MS_PER_HOUR, - routeDistance, - travelTimeGameHours, - realToGame, validateDispatch, validateReposition, wouldStrand, @@ -31,7 +27,7 @@ const pilot: PilotState = { /** * Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can * reach the Moon from Earth but can't climb back out. "moon-station" shares the - * Moon's orbit, so it is always in reach from there. + * Moon's position, so it is always in reach from there. */ const heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 }; const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" }; @@ -95,38 +91,16 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => { ).toEqual({ ok: true }); }); - it("never strands a base ship on a trip it is allowed to take between the five locations", () => { - const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); - const month = 30.44 * 24 * 3_600_000; + it("never blocks a base ship on a leg it can fly between the five locations (fixed map)", () => { const ids = content.locations.map((l) => l.id); - const canLeave = (model: ShipModel, at: Location, t: number) => - content.locations.some((next) => next.id !== at.id && checkRoute(model, at, next, t).ok); for (const base of content.shipModels) { const model = effectiveStats(base, {}, content); for (const from of ids) { for (const to of ids) { - if (from === to) continue; - for (let i = 0; i <= 16 * 12; i++) { - const t = start + i * month; - if (!checkRoute(model, loc(from), loc(to), t).ok) continue; - if (wouldStrand(model, loc(from), loc(to), 0, t, undefined, content)) continue; - const hours = travelTimeGameHours( - model, - routeDistance(loc(from), loc(to), t), - 0, - content.config, - ); - 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); - } + if (from === to || !checkRoute(model, loc(from), loc(to)).ok) continue; + expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe( + false, + ); } } } diff --git a/packages/sim/test/pay-rate.test.ts b/packages/sim/test/pay-rate.test.ts index 9e0a6e0..8d1f511 100644 --- a/packages/sim/test/pay-rate.test.ts +++ b/packages/sim/test/pay-rate.test.ts @@ -1,12 +1,6 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { - contractPayout, - generateContractBoard, - realToGame, - routeDistance, - type Contract, -} from "../src/index"; +import { contractPayout, generateContractBoard, routeDistance, type Contract } from "../src/index"; const { config } = content; const v = config.contractPayVariance; @@ -24,7 +18,6 @@ function basePayout(c: Contract): number { distanceMillionKm: routeDistance( content.location(c.originId)!, content.location(c.destinationId)!, - realToGame(c.offeredAt, config), ), firstContact: c.firstContact, priority: c.priority, diff --git a/packages/sim/test/positions.test.ts b/packages/sim/test/positions.test.ts index 9091cf8..e98896e 100644 --- a/packages/sim/test/positions.test.ts +++ b/packages/sim/test/positions.test.ts @@ -2,9 +2,8 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { locationPosition } from "../src/index"; -const when = Date.parse("2100-03-01T00:00:00Z"); const fromSun = (id: string) => { - const { x, y } = locationPosition(content.location(id)!, when); + const { x, y } = locationPosition(content.location(id)!); return Math.hypot(x, y); }; @@ -29,14 +28,14 @@ describe("location positions", () => { }); it("keeps the Moon within 1 million km of Earth", () => { - const earth = locationPosition(content.location("earth")!, when); - const moon = locationPosition(content.location("moon")!, when); + const earth = locationPosition(content.location("earth")!); + const moon = locationPosition(content.location("moon")!); expect(Math.hypot(moon.x - earth.x, moon.y - earth.y)).toBeLessThan(1); }); - it("returns identical values for the same time", () => { + it("never moves (decision 0.5.93)", () => { for (const location of content.locations) { - expect(locationPosition(location, when)).toEqual(locationPosition(location, when)); + expect(locationPosition(location)).toEqual(locationPosition(location)); } }); }); diff --git a/packages/sim/test/priority.test.ts b/packages/sim/test/priority.test.ts index 461a6ec..7b95ed7 100644 --- a/packages/sim/test/priority.test.ts +++ b/packages/sim/test/priority.test.ts @@ -28,9 +28,7 @@ function mailContract(priority: boolean): Contract { periodIndex, shipper: earth.shippers[0]!, priority, - deliveryWindowGameHours: priority - ? priorityWindow(earth, moon, massTonnes, offeredAt, content) - : null, + deliveryWindowGameHours: priority ? priorityWindow(earth, moon, massTonnes, content) : null, }, content, ); @@ -69,7 +67,7 @@ describe("Priority validation (CR-007)", () => { it("measures the trip the same way the window was set", () => { const contract = mailContract(true); const freighter = content.shipModel("freighter")!; - expect(contractTripGameHours(freighter, contract, offeredAt, content)).toBeCloseTo( + expect(contractTripGameHours(freighter, contract, content)).toBeCloseTo( contract.deliveryWindowGameHours!, ); }); diff --git a/packages/sim/test/range-from.test.ts b/packages/sim/test/range-from.test.ts index dae3a70..446ab74 100644 --- a/packages/sim/test/range-from.test.ts +++ b/packages/sim/test/range-from.test.ts @@ -1,13 +1,10 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { checkRoute, rangeFrom, realToGame, routeDistance } from "../src/index"; +import { checkRoute, rangeFrom, routeDistance } from "../src/index"; const loc = (id: string) => content.location(id)!; const courier = content.shipModel("courier")!; const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range }); -const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); -const MONTH = 30.44 * 24 * 3_600_000; -const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH); describe("range from the origin (Step 10L.2)", () => { it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => { @@ -17,28 +14,25 @@ describe("range from the origin (Step 10L.2)", () => { }); it("passes a leg from the Moon that fails from Earth when it is between 1× and 1.3× range", () => { - const t = samples[0]!; - const d = routeDistance(loc("moon"), loc("mars"), t); + const d = routeDistance(loc("moon"), loc("mars")); const ship = withRange(d / 1.2); - expect(routeDistance(loc("earth"), loc("mars"), t)).toBeGreaterThan(ship.fuelRangeMillionKm); - expect(checkRoute(ship, loc("earth"), loc("mars"), t)).toEqual({ + expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(ship.fuelRangeMillionKm); + expect(checkRoute(ship, loc("earth"), loc("mars"))).toEqual({ ok: false, reason: "out-of-range", }); - expect(checkRoute(ship, loc("moon"), loc("mars"), t)).toEqual({ ok: true }); + expect(checkRoute(ship, loc("moon"), loc("mars"))).toEqual({ ok: true }); }); it("matches the old check exactly when every factor is 1", () => { const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 }); const ids = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]; - for (const range of [5, 198, 450, 1250]) { + for (const range of [5, 250, 460, 1250]) { for (const from of ids) { for (const to of ids) { if (from === to) continue; - for (const t of samples) { - const old = routeDistance(loc(from), loc(to), t) <= range; - expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), t).ok).toBe(old); - } + const old = routeDistance(loc(from), loc(to)) <= range; + expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to))).ok).toBe(old); } } } diff --git a/packages/sim/test/reach-table.test.ts b/packages/sim/test/reach-table.test.ts index cdce260..2cac9ef 100644 --- a/packages/sim/test/reach-table.test.ts +++ b/packages/sim/test/reach-table.test.ts @@ -6,7 +6,6 @@ import { checkRoute } from "../src/index"; * CR-015 §4: reach for base ships on the fixed map, a plain yes or no per leg * (decision 0.5.81 as amended). Replaces CR-012's date-sampled table. */ -const now = Date.parse("2100-01-01T00:00:00Z"); const NAMES: Record = { earth: "Earth", moon: "Moon", @@ -18,7 +17,7 @@ const ids = Object.keys(NAMES); function reaches(modelId: string, fromId: string, toId: string): boolean { const ship = content.shipModel(modelId)!; - return checkRoute(ship, content.location(fromId)!, content.location(toId)!, now).ok; + return checkRoute(ship, content.location(fromId)!, content.location(toId)!).ok; } /** [model, from, to] for every leg the table says yes or no to. */ diff --git a/packages/sim/test/static-routes.test.ts b/packages/sim/test/static-routes.test.ts new file mode 100644 index 0000000..2d271f6 --- /dev/null +++ b/packages/sim/test/static-routes.test.ts @@ -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]); + }); +}); diff --git a/packages/sim/test/travel.test.ts b/packages/sim/test/travel.test.ts index ae50dbe..41413ed 100644 --- a/packages/sim/test/travel.test.ts +++ b/packages/sim/test/travel.test.ts @@ -6,12 +6,7 @@ const config = content.config; const courier = content.shipModel("courier")!; const freighter = content.shipModel("freighter")!; const loaner = content.shipModel("company-courier")!; -const earthMoon = () => - routeDistance( - content.location("earth")!, - content.location("moon")!, - Date.parse("2100-01-01T00:00:00Z"), - ); +const earthMoon = () => routeDistance(content.location("earth")!, content.location("moon")!); describe("travel time", () => { it("matches a hand calculation, including handling time", () => { diff --git a/packages/sim/test/tutorial.test.ts b/packages/sim/test/tutorial.test.ts index 05097f7..7ef2025 100644 --- a/packages/sim/test/tutorial.test.ts +++ b/packages/sim/test/tutorial.test.ts @@ -7,7 +7,6 @@ import { generateTutorialBoard, loanerModel, MS_PER_HOUR, - realToGame, routeDistance, travelTimeGameHours, type Contract, @@ -25,7 +24,6 @@ function distanceOf(contract: Contract): number { return routeDistance( content.location(contract.originId)!, content.location(contract.destinationId)!, - realToGame(contract.offeredAt, config), ); } diff --git a/packages/sim/test/upgrade-caps.test.ts b/packages/sim/test/upgrade-caps.test.ts index 6619828..cce4cc7 100644 --- a/packages/sim/test/upgrade-caps.test.ts +++ b/packages/sim/test/upgrade-caps.test.ts @@ -1,6 +1,6 @@ import { content, upgradeMaxLevel, upgradePercentPerLevel, upgrades } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { checkRoute, effectiveStats, realToGame, routeDistance } from "../src/index"; +import { checkRoute, effectiveStats } from "../src/index"; /** Every upgrade at its highest level for the model (decision 0.5.63). */ const maxedStats = (modelId: string) => @@ -10,9 +10,6 @@ const maxedStats = (modelId: string) => content, ); -const MONTH = 30.44 * 24 * 3_600_000; -const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); -const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH); const earth = content.location("earth")!; const moon = content.location("moon")!; const mars = content.location("mars")!; @@ -22,7 +19,7 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => { it("a maxed Lighter never reaches Mars from Earth or the Moon", () => { const lighter = maxedStats("lighter"); for (const from of [earth, moon]) { - for (const t of samples) expect(checkRoute(lighter, from, mars, t).ok).toBe(false); + expect(checkRoute(lighter, from, mars).ok).toBe(false); } }); @@ -38,12 +35,9 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => { ); }); - it("only Bulk Haulers reach Jupiter from Earth at its farthest, even maxed", () => { - const farthest = samples.reduce((a, b) => - routeDistance(earth, jupiter, b) > routeDistance(earth, jupiter, a) ? b : a, - ); + it("only Bulk Haulers reach Jupiter from Earth, even maxed", () => { for (const ship of content.shipModels) { - const reaches = checkRoute(maxedStats(ship.id), earth, jupiter, farthest).ok; + const reaches = checkRoute(maxedStats(ship.id), earth, jupiter).ok; expect(reaches, ship.id).toBe(ship.id === "bulk-hauler"); } });