diff --git a/DECISIONS.md b/DECISIONS.md index 85fc335..88785f8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -970,3 +970,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The client decides what's enabled with the sim's `validateUpgrade`**, using "a bay where the ship is" for at-HQ, exactly as the server does. - **"In the shop" is decided by the clock** (`inShopUntil` vs now), so the countdown and the card's actions return the moment the work is done, without a refetch. - **Handling time reads in game minutes per tonne** (1.20 → 1.02 min/t) and overhead in hours, so small changes stay visible. + +## Step 10I.7 + +- **The client computes effective stats with the sim** (`shipStatsOf`) from the ship's model and upgrade levels, rather than reading the API's `stats`, so predictions run on the exact functions the server uses. `stats` stays in the API for display and other clients. +- **"Market ship comparison for owned ships"**: the Market had no owned-ship comparison, so each model card now lists the player's upgraded ships of that model with their effective speed, capacity and range under the base specs. +- **Map reachability** keeps listing purchasable models (base stats) and adds the player's upgraded ships that reach only because of their upgrades, e.g. "your Dusty Mule (upgraded)" for a Courier with Fuel Tanks 3 near Mars's closest approach. diff --git a/apps/web/src/components/Goods.tsx b/apps/web/src/components/Goods.tsx index 81adc43..cfbe224 100644 --- a/apps/web/src/components/Goods.tsx +++ b/apps/web/src/components/Goods.tsx @@ -1,6 +1,7 @@ import { content } from "@deadhead/content"; import type { Contract, PlayerState } from "@deadhead/schema"; import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim"; +import { shipStatsOf } from "../game"; import { useState } from "react"; import { cargoName, formatDuration } from "../format"; import { Pill, type Tone } from "../ui"; @@ -70,11 +71,10 @@ export function tooSlowForAll(state: PlayerState, contract: Contract, now: numbe 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; - return idle.every((ship) => { - const model = content.shipModel(ship.modelId); - return ( - !model || - contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours! - ); - }); + // Effective stats: an engines upgrade can make the window (CR-009). + return idle.every( + (ship) => + contractTripGameHours(shipStatsOf(ship), contract, now, content) > + contract.deliveryWindowGameHours!, + ); } diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index d210773..cb78d60 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -1,5 +1,14 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; -import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; +import type { Ship } from "@deadhead/schema"; +import { + effectiveStats, + MS_PER_HOUR, + realToGame, + routeDistance, + travelTimeGameHours, + type EffectiveStats, + type ShipState, +} from "@deadhead/sim"; /** Client-side predictions with the same sim the server uses (rule 0.1.6). */ @@ -40,3 +49,20 @@ export function tripRealMs( ); return (hours * MS_PER_HOUR) / content.config.timeCompression; } + +/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */ +export function shipStatsOf(ship: Pick): EffectiveStats { + return effectiveStats(shipModel(ship.modelId), ship.upgrades, content); +} + +/** The sim's view of an owned ship: location, flight, upgrades and shop time (CR-009). */ +export function simShip(ship: Ship): ShipState { + return { + id: ship.id, + modelId: ship.modelId, + locationId: ship.locationId ?? "", + inFlight: ship.status === "in-flight", + upgrades: ship.upgrades, + inShopUntil: ship.inShopUntil, + }; +} diff --git a/apps/web/src/map/reach.ts b/apps/web/src/map/reach.ts index 990ed54..a38cca2 100644 --- a/apps/web/src/map/reach.ts +++ b/apps/web/src/map/reach.ts @@ -1,5 +1,6 @@ import { content, type ShipModel } from "@deadhead/content"; -import { checkRoute, realToGame } from "@deadhead/sim"; +import type { Ship } from "@deadhead/schema"; +import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim"; /** * Purchasable ship models that can fly from Earth to `locationId` at real time @@ -14,3 +15,26 @@ export function modelsReachingFromEarth(locationId: string, now: number): ShipMo (model) => checkRoute(model, earth, target, gameTime).ok, ); } + +/** + * The player's own ships that reach `locationId` from Earth only thanks to + * their upgrades (effective stats, CR-009): the base model can't, they can. + */ +export function upgradedShipsReachingFromEarth( + ships: readonly Ship[], + locationId: string, + now: number, +): Ship[] { + const earth = content.location("earth"); + const target = content.location(locationId); + if (!earth || !target) return []; + const gameTime = realToGame(now, content.config); + return ships.filter((ship) => { + const model = content.shipModel(ship.modelId); + if (!model?.purchasable || Object.keys(ship.upgrades).length === 0) return false; + return ( + !checkRoute(model, earth, target, gameTime).ok && + checkRoute(effectiveStats(model, ship.upgrades, content), earth, target, gameTime).ok + ); + }); +} diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 54c6821..1c4daf6 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -3,6 +3,7 @@ import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import { checkRoute, contractTripGameHours, + isInShop, dutyAfterTrip, LONG_HAUL_SKILL_ID, needsRest, @@ -25,7 +26,7 @@ import { skillName, } from "../format"; import { requirementsText } from "./LocationBoards"; -import { location, shipModel, tripRealMs } from "../game"; +import { location, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Sheet } from "../ui"; @@ -150,7 +151,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) return state.ships .filter((ship) => ship.status === "idle" && ship.locationId === contract.originId) .map((ship) => { - const model = shipModel(ship.modelId); + // Effective stats: upgrades change capacity, range and trip time (CR-009). + const model = shipStatsOf(ship); const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const tripMs = tripRealMs( model, @@ -171,12 +173,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) if (pilot) { const check = validateDispatch( { - ship: { - id: ship.id, - modelId: ship.modelId, - locationId: ship.locationId!, - inFlight: false, - }, + ship: simShip(ship), pilot: { id: pilot.id, isAvatar: pilot.isAvatar, @@ -197,6 +194,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) problems = []; } else { const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now); + // In the shop (CR-009): first, with the time left. + if (isInShop(ship, now)) + all.unshift(`In the shop — ${formatClock(ship.inShopUntil! - now)}`); problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]]; } } diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 678954b..cf7fcee 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -13,7 +13,7 @@ import { useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; import { ShipUpgrades } from "./ShipUpgrades"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; -import { location, shipModel, tripRealMs } from "../game"; +import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Art, @@ -202,7 +202,8 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v const state = useLoadedPlayer(); const now = useClock((s) => s.now); const reposition = useGameAction("createDispatch"); - const model = shipModel(ship.modelId); + // Effective stats: upgraded fuel tanks reach farther (CR-009). + const model = shipStatsOf(ship); const origin = ship.locationId!; const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id); @@ -214,7 +215,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v .map((id) => { const check = validateReposition( { - ship: { id: ship.id, modelId: ship.modelId, locationId: origin, inFlight: false }, + ship: simShip(ship), pilot: { id: pilot?.id ?? "", isAvatar: pilot?.isAvatar ?? false, diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index b0b1041..bb98a89 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -6,7 +6,7 @@ import { useApi } from "../api/context"; import { serverNow, useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format"; -import { distanceAt } from "../game"; +import { distanceAt, shipStatsOf } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { pairOptions } from "./DispatchSheet"; import { DeadheadPicker } from "./Fleet"; @@ -79,7 +79,8 @@ export function carryLimit(state: PlayerState, locationId: string): number | nul const here = state.ships.filter((s) => s.locationId === locationId); const ships = here.length > 0 ? here : state.ships; if (ships.length === 0) return null; - return Math.max(...ships.map((s) => content.shipModel(s.modelId)?.cargoCapacityTonnes ?? 0)); + // Effective capacity: an expanded cargo bay carries more (CR-009). + return Math.max(...ships.map((s) => shipStatsOf(s).cargoCapacityTonnes)); } /** diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 4a4decc..a63673a 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -10,7 +10,7 @@ import { distanceAt } from "../game"; import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import type { MapSelection } from "../map/hitTest"; import { MapCanvas } from "../map/MapCanvas"; -import { modelsReachingFromEarth } from "../map/reach"; +import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach"; import { useLoadedPlayer } from "../state/actions"; import { Art, buttonClass, Icon } from "../ui"; @@ -96,6 +96,12 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: ( const discovered = state.locations.some((l) => l.id === locationId && l.discovered); const hasBay = state.bays.some((b) => b.locationId === locationId); const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now); + // Owned ships whose upgrades get them there when their model can't (CR-009). + const upgraded = discovered ? [] : upgradedShipsReachingFromEarth(state.ships, locationId, now); + const reachers = [ + ...reachable.map((m) => m.name), + ...upgraded.map((s) => `your ${s.name} (upgraded)`), + ]; return (

- {reachable.length > 0 - ? `Reachable with: ${reachable.map((m) => m.name).join(", ")}` + {reachers.length > 0 + ? `Reachable with: ${reachers.join(", ")}` : "No ship can reach it from Earth right now."}

diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index 96b81b7..d2f4977 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -1,6 +1,7 @@ import { content, type ShipModel } from "@deadhead/content"; import { useState } from "react"; import { formatCredits, formatMillionKm, locationName } from "../format"; +import { shipStatsOf } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; @@ -84,6 +85,7 @@ export function Market() { /> +