diff --git a/DECISIONS.md b/DECISIONS.md index 8baeff6..a815750 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1182,3 +1182,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Origins are discovered locations with an HQ or an idle ship**, in content order. In-flight ships don't count (they aren't anywhere yet). A player with neither, as in the tutorial, is measured from Earth, as before. - **Upgraded ships are listed under the origin they are at**, only when their base model can't make the leg from there but their effective stats can ("your Swift Heron (upgraded)"). Before, they were checked from Earth wherever they were. - **Origins with nobody are left out**; if none can reach, the panel says "No ship can reach it from your bases right now." + +## Step 10L.9 + +- **The client now passes discovered ids** to `validateDispatch`, so the sheet agrees with the server (10L.3 left it lenient). +- **The stranding line replaces the range line, never adds to it**: it is shown only when the leg itself is in range, matching the sim's check order. +- **`wouldStrand` accepts a bare `ShipModel`** (travel time already falls back to the game-wide handling rate), so the sheet's explainer can use it with the stats it already has. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index b3b4d5d..c860807 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -13,6 +13,7 @@ import { tripFuelCost, tripGameHours, validateDispatch, + wouldStrand, type DispatchFailure, } from "@deadhead/sim"; import { useState } from "react"; @@ -60,6 +61,17 @@ export const INELIGIBLE_REASONS: Record = { "insufficient-credits": "Not enough credits for fuel", }; +/** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */ +export function strandedText(destinationId: string): string { + const name = locationName(destinationId); + return `Would be stranded — can't fly out of ${name} from there`; +} + +/** Locations the player has discovered, for the no-strand rule. */ +export function discoveredIdsOf(state: PlayerState): Set { + return new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)); +} + /** * Every reason a ship and pilot cannot take a contract, in plain words with the * numbers involved, e.g. "Too heavy: 228 t (holds 20 t)". Display only: the @@ -97,6 +109,18 @@ export function explainProblems( ? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId)))}M km from ${locationName(contract.originId)})` : INELIGIBLE_REASONS["cannot-land"], ); + } else if ( + wouldStrand( + model, + location(contract.originId), + location(contract.destinationId), + contract.massTonnes, + gameNow, + 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; @@ -204,6 +228,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) tutorial: false, credits: state.company.credits, now, + discoveredIds: discoveredIdsOf(state), }, content, ); diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 142a105..64df130 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -12,6 +12,7 @@ import { Link } from "react-router"; import { useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; import { ShipUpgrades } from "./ShipUpgrades"; +import { strandedText } from "./DispatchSheet"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; @@ -234,7 +235,11 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v ); return { id, - problem: check.ok ? null : (DEADHEAD_REASONS[check.reason] ?? check.reason), + problem: check.ok + ? null + : check.reason === "would-strand" + ? strandedText(id) + : (DEADHEAD_REASONS[check.reason] ?? check.reason), fuelCost: tripFuelCost(model, location(origin), location(id), gameNow), tripMs: tripRealMs(model, origin, id, 0, now), }; diff --git a/apps/web/test/stranding-reason.test.tsx b/apps/web/test/stranding-reason.test.tsx new file mode 100644 index 0000000..fda4dcc --- /dev/null +++ b/apps/web/test/stranding-reason.test.tsx @@ -0,0 +1,99 @@ +import { content } from "@deadhead/content"; +import type { Contract, PlayerState } from "@deadhead/schema"; +import { checkRoute, gameToReal, realToGame, 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"; +import { useClock } from "../src/clock/clock"; +import { pairOptions } from "../src/screens/DispatchSheet"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const DAY = 24 * 3_600_000; +const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config); +const clipper = content.shipModel("clipper")!; +const [moon, mars] = [content.location("moon")!, content.location("mars")!]; +const discovered = new Set(["earth", "moon", "mars"]); + +/** A date on which a Clipper can hop Moon→Mars but couldn't leave Mars (CR-012). */ +const NOW = (() => { + for (let d = 0; d < 16 * 365; d += 5) { + const t = gameStart + d * DAY; + if ( + checkRoute(clipper, moon, mars, t).ok && + wouldStrand(clipper, moon, mars, 10, t, discovered, content) + ) { + return gameToReal(t, content.config); + } + } + throw new Error("no stranding date"); +})(); +const STRANDED = "Would be stranded — can't fly out of Mars from there"; + +/** A crewed Clipper at the Moon, with Mars discovered. */ +function state(): PlayerState { + const base = samplePlayerState({ tutorial: null }); + return { + ...base, + company: { ...base.company, credits: 100_000 }, + bays: [{ id: "b1", locationId: "moon" }], + locations: base.locations.map((l) => ({ ...l, discovered: discovered.has(l.id) })), + ships: [ + { + id: "s1", + modelId: "clipper", + name: "Swift Heron", + status: "idle", + locationId: "moon", + dispatchId: null, + lastArrivalAt: 0, + ...shipExtras("clipper"), + pilotId: "avatar", + }, + ], + pilots: [{ ...base.pilots[0]!, assignedShipId: "s1", locationId: "moon" }], + }; +} + +const toMars: Contract = { + id: "c1", + originId: "moon", + destinationId: "mars", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 10, + payout: 50_000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + firstContact: false, +}; + +describe("stranding reason (Step 10L.9)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("marks a would-strand ship and pilot pair with the reason", () => { + const [pair] = pairOptions(state(), toMars, NOW); + expect(pair!.problems).toEqual([STRANDED]); + }); + + it("disables a would-strand deadhead destination with the reason", async () => { + renderApp(fakeApi({ getPlayerState: state }), "/fleet"); + const card = (await screen.findByText("Swift Heron")).closest("li")!; + await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ })); + const dialog = screen.getByRole("dialog", { name: "Deadhead Swift Heron" }); + const option = within(dialog).getByRole("button", { name: "Mars" }); + expect(option).toBeDisabled(); + expect(option).toHaveTextContent(STRANDED); + expect(within(dialog).getByRole("button", { name: "Earth" })).toBeEnabled(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e10083c..832a8ca 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -277,6 +277,6 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as - [x] **Step 10L.6 — Schema and server.** `would-strand` is in `ERROR_CODES` (it comes from the sim's failure lists) with a server message; buy-ship already accepts any purchasable model. Tests (`apps/server/test/lighter-clipper.test.ts`): buying a Lighter and a Clipper places them idle at the chosen HQ; a Clipper at the Moon deadheading to Mars on a stranding date gets 409 `would-strand` and credits, ships and flights are unchanged. - [x] **Step 10L.7 — Client: Market shows roles and reach.** Each Market card shows the model's role and a Reach line from `shipReach` (`apps/web/src/game.ts`, sim `checkRoute`): "From Earth now: Moon, Mars · Other dates: … · By hopping: Ceres Station", with one "From X now" part per HQ location. "Other dates" lists places reachable directly from an HQ but not today; "By hopping" lists places reachable only through other locations (each leg in range on some monthly date over 16 game years). Tests (`market-reach.test.tsx`): roles; the Clipper lists Mars from Earth only on a date when it is in range (otherwise under Other dates) and always lists Ceres by hopping; the Lighter lists only the Moon. - [x] **Step 10L.8 — Client: reachability from where ships actually are.** An undiscovered location's map panel now reads "From Mars: Clipper, Freighter · From Earth: Freighter": one part per discovered location with an HQ or idle ship (`playerOrigins`, Earth when there are none yet), listing purchasable models in range from there now plus the player's ships there that reach it only thanks to upgrades (`reachersByOrigin` in `map/reach.ts`, replacing the Earth-only helpers). Tests: `map-reach-origins.test.tsx` (a Clipper at Mars on a date Mars→Ceres is in range: Ceres lists Clipper from Mars, not from Earth); `map-locked.test.tsx` now expects "From Earth: …" for a tutorial player. -- [ ] Step 10L.9 — Client: stranding reason +- [x] **Step 10L.9 — Client: stranding reason.** The dispatch sheet passes the player's discovered ids to `validateDispatch` and explains a stranding pair as "Would be stranded — can't fly out of Mars from there" (`strandedText`); the deadhead picker disables such a destination with the same text. `wouldStrand` now takes a bare model or effective stats. Test (`stranding-reason.test.tsx`, a Clipper at the Moon on a date it could hop to Mars but not leave): the pair's only problem is the stranding text; the Mars deadhead is disabled with it while Earth stays enabled. - [ ] Step 10L.10 — Art - [ ] Step 10L.11 — Plan amendments and regression diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 004b0c3..455ae70 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -119,7 +119,8 @@ export function contractTripGameHours( * location. `discoveredIds` absent means every location counts. */ export function wouldStrand( - model: EffectiveStats, + /** Effective stats, or a bare model (handling falls back to the game-wide base). */ + model: ShipModel, origin: Location, destination: Location, massTonnes: number,