From 750ce54b7ddef10ad57c00fe40769e101d6e24f5 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 01:48:09 -0400 Subject: [PATCH] Step 10L.7: Market shows roles and reach Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 +++ apps/web/src/game.ts | 73 +++++++++++++++++++++++++++++ apps/web/src/screens/Market.tsx | 34 +++++++++++++- apps/web/test/market-reach.test.tsx | 64 +++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 apps/web/test/market-reach.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 75a4015..b6cba68 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1169,3 +1169,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids). - **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently. + +## Step 10L.7 + +- **A third part, "Other dates", was added** to the CR's "From Earth now … · By hopping …": without it a Clipper on a far-Mars date would show Mars nowhere, though it can fly there directly later. It lists places an HQ reaches directly on some date but not today. +- **"By hopping" means reachable only through other locations**, over legs each in range on some date in the 16-year horizon; places an HQ reaches directly never appear there. Hops are not checked for timing (that is the player's job), and undiscovered places are included because the Market describes the ship, not the player's map. +- **The horizon starts at the game epoch** and is computed once per model, then cached; only the "now" part depends on the current date. The line is computed at render from the server clock, not ticked, since range changes over game days. +- **Origins are the player's HQ locations** (one part each); a player with no bays sees no Reach line. diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index cb78d60..8f47d41 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -1,12 +1,14 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; import type { Ship } from "@deadhead/schema"; import { + checkRoute, effectiveStats, MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours, type EffectiveStats, + type RealTime, type ShipState, } from "@deadhead/sim"; @@ -66,3 +68,74 @@ export function simShip(ship: Ship): ShipState { inShopUntil: ship.inShopUntil, }; } + +/** 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>(); + +/** Legs a model flies on at least one sampled date. */ +function everInRange(model: ShipModel): Set { + let legs = everCache.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)); + } + } + } + everCache.set(model.id, legs); + } + return legs; +} + +export interface ShipReach { + /** Per origin, the locations in range right now. */ + now: { originId: string; destinationIds: string[] }[]; + /** Reachable directly from an origin, but not right now. */ + otherDates: string[]; + /** Reachable only through other locations. */ + byHopping: string[]; +} + +/** + * 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). + */ +export function shipReach(model: ShipModel, originIds: string[], now: RealTime): ShipReach { + const t = realToGame(now, content.config); + const ever = everInRange(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, + ), + })); + const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds)); + const direct = new Set(ids.filter((to) => originIds.some((o) => ever.has(legKey(o, to))))); + const reached = new Set(originIds); + const queue = [...originIds]; + while (queue.length > 0) { + const from = queue.shift()!; + for (const to of ids) { + if (!reached.has(to) && ever.has(legKey(from, to))) { + reached.add(to); + queue.push(to); + } + } + } + return { + now: nowLegs, + otherDates: ids.filter((id) => direct.has(id) && !inRangeNow.has(id) && !origins.has(id)), + byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)), + }; +} diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index d2f4977..eac3b45 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -1,7 +1,8 @@ import { content, type ShipModel } from "@deadhead/content"; import { useState } from "react"; import { formatCredits, formatMillionKm, locationName } from "../format"; -import { shipStatsOf } from "../game"; +import { serverNow } 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"; @@ -13,7 +14,7 @@ const best = { capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)), range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)), }; -/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */ +/** Range spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */ const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range); /** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */ @@ -64,6 +65,7 @@ export function Market() { {formatCredits(model.price)} +

{model.role}

+