Step 10I.7: effective ship stats across the client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e205194758
commit
b5f2801076
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Ship, "modelId" | "upgrades">): 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Panel
|
||||
|
|
@ -138,8 +144,8 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
Undiscovered — deliver here to unlock.
|
||||
</p>
|
||||
<p aria-label="Reachable with" className="text-ink-200">
|
||||
{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."}
|
||||
</p>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
|
||||
</dl>
|
||||
<OwnedUpgraded modelId={model.id} />
|
||||
<Button
|
||||
aria-label={`Buy ${model.name}`}
|
||||
block
|
||||
|
|
@ -187,3 +189,26 @@ function DeliveryPicker(props: {
|
|||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** The player's upgraded ships of a model, with their effective stats, to compare (CR-009). */
|
||||
function OwnedUpgraded({ modelId }: { modelId: string }) {
|
||||
const state = useLoadedPlayer();
|
||||
const upgraded = state.ships.filter(
|
||||
(s) => s.modelId === modelId && Object.keys(s.upgrades).length > 0,
|
||||
);
|
||||
if (upgraded.length === 0) return null;
|
||||
return (
|
||||
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-cyan-200">
|
||||
{upgraded.map((ship) => {
|
||||
const s = shipStatsOf(ship);
|
||||
return (
|
||||
<li key={ship.id}>
|
||||
Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "}
|
||||
{Math.round(s.cargoCapacityTonnes * 10) / 10} t ·{" "}
|
||||
{formatMillionKm(s.fuelRangeMillionKm)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||
import { gameToReal, realToGame, routeDistance } 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";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
/** Mars near closest approach in the first two game years (~82M km, decision 0.5.68). */
|
||||
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
|
||||
const NOW = gameToReal(gameStart + 23 * 30.44 * 24 * 3_600_000, content.config);
|
||||
|
||||
const courier = (upgrades: Record<string, number>): Ship => ({
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
name: "Dusty Mule",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: "p1",
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier", upgrades),
|
||||
});
|
||||
const pilot: Pilot = {
|
||||
id: "p1",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
skillPointsAvailable: 0,
|
||||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
/** 22 t: one tonne more than a base Courier's 20, within a Cargo Bay 1 Courier's 23. */
|
||||
const job: Contract = {
|
||||
id: "c22",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
goodId: "consumer-goods",
|
||||
shipper: "Pacific Orbital Logistics",
|
||||
priority: false,
|
||||
deliveryWindowGameHours: null,
|
||||
tier: 0,
|
||||
massTonnes: 22,
|
||||
payout: 1234,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 30 * 60_000,
|
||||
firstContact: false,
|
||||
};
|
||||
|
||||
const api = (upgrades: Record<string, number>) =>
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
||||
bays: [{ id: "b", locationId: "earth" }],
|
||||
ships: [courier(upgrades)],
|
||||
pilots: [pilot],
|
||||
locations: content.locations.map((l) => ({
|
||||
id: l.id,
|
||||
discovered: ["earth", "moon", "mars"].includes(l.id),
|
||||
})),
|
||||
}),
|
||||
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
|
||||
});
|
||||
|
||||
describe("effective stats in the client (Step 10I.7)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("drops the Too heavy badge once the cargo bay is expanded", async () => {
|
||||
const { unmount } = renderApp(api({}), "/contracts?location=earth");
|
||||
expect((await screen.findByText("1,234 cr")).closest("li")!).toHaveTextContent("Too heavy");
|
||||
unmount();
|
||||
|
||||
renderApp(api({ "cargo-bay": 1 }), "/contracts?location=earth");
|
||||
expect((await screen.findByText("1,234 cr")).closest("li")!).not.toHaveTextContent("Too heavy");
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
expect(distance).toBeLessThan(95);
|
||||
|
||||
const open = async () => {
|
||||
const card = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
|
||||
return within(screen.getByRole("dialog", { name: "Deadhead Dusty Mule" })).getByRole(
|
||||
"button",
|
||||
{ name: "Mars" },
|
||||
);
|
||||
};
|
||||
const { unmount } = renderApp(api({}), "/fleet");
|
||||
expect(await open()).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
renderApp(api({ "fuel-tanks": 3 }), "/fleet");
|
||||
expect(await open()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -232,6 +232,6 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
|
|||
- [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated.
|
||||
- [x] **Step 10I.5 — Server: start upgrade.** `startUpgrade` (`game/upgrades.ts`) runs inside `withPlayer` (company row lock): sim `validateUpgrade` (at an HQ = a bay where the ship is), charge, upsert `ship_upgrades`, set in-shop-until and the shop job; returns the ship. Unknown upgrade → `not-found`. Dispatch and deadhead pass the ship's upgrades and in-shop time to the sim (effective stats, `ship-in-shop`); fleet-fitted boards use effective capacity. Test: `upgrades.test.ts` (engines on a Freighter: cost, level, shop time, refused then faster trip; `not-at-hq` at the Moon; two simultaneous upgrades → one).
|
||||
- [x] **Step 10I.6 — Client: upgrade shop.** Every Fleet card has "Upgrades" (with the count installed), opening `ShipUpgrades` (`screens/ShipUpgrades.tsx`): each of the five upgrades with level n/max, the next level as before → after from the sim's `effectiveStats` (e.g. "Speed 2.20M km/h → 2.40M km/h", "Capacity 100 t → 115 t"), cost and shop time; disabled ones show the sim's reason (Not at an HQ, In flight, In the shop, Maxed, Can't afford, Company ship, Finish the company runs first). Upgrading asks for confirmation, then calls `startUpgrade`. Ships in the shop show an "In the shop" pill and "In the shop — mm:ss" instead of their actions. Test: `fleet-upgrades.test.tsx`.
|
||||
- [ ] Step 10I.7 — Client: effective stats everywhere
|
||||
- [x] **Step 10I.7 — Client: effective stats everywhere.** `game.ts` `shipStatsOf(ship)` (sim `effectiveStats` from model + upgrades) and `simShip(ship)` (sim `ShipState` with upgrades and in-shop-until). Used by: the board's Too heavy badge (`carryLimit`), Too slow (`tooSlowForAll`), the dispatch sheet (problems, trip time, Priority window, validation; an in-shop ship lists "In the shop — mm:ss" first), and the deadhead picker. The Market lists your upgraded ships of each model with their effective speed, capacity and range; the map's "Reachable with" adds "your [ship] (upgraded)" when only upgrades get it there. Test: `effective-stats-ui.test.tsx` (Cargo Bay 1 clears Too heavy on a 22 t job; Fuel Tanks 3 enables Mars in the deadhead picker near closest approach).
|
||||
- [ ] Step 10I.8 — Client: shop in summaries and on HQ
|
||||
- [ ] Step 10I.9 — Plan amendments and regression
|
||||
|
|
|
|||
Loading…
Reference in New Issue