Step 10L.9: stranding reason in the dispatch sheet and deadhead picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0658c9984d
commit
05e7fbc535
|
|
@ -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.
|
- **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.
|
- **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."
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
tripFuelCost,
|
tripFuelCost,
|
||||||
tripGameHours,
|
tripGameHours,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
|
wouldStrand,
|
||||||
type DispatchFailure,
|
type DispatchFailure,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -60,6 +61,17 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
"insufficient-credits": "Not enough credits for fuel",
|
"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<string> {
|
||||||
|
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
|
* 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
|
* 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)})`
|
? `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"],
|
: 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.
|
// Priority (CR-007): the trip must fit the delivery window.
|
||||||
const window = contract.priority ? contract.deliveryWindowGameHours : null;
|
const window = contract.priority ? contract.deliveryWindowGameHours : null;
|
||||||
|
|
@ -204,6 +228,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: state.company.credits,
|
credits: state.company.credits,
|
||||||
now,
|
now,
|
||||||
|
discoveredIds: discoveredIdsOf(state),
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { ShipUpgrades } from "./ShipUpgrades";
|
import { ShipUpgrades } from "./ShipUpgrades";
|
||||||
|
import { strandedText } from "./DispatchSheet";
|
||||||
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||||
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
@ -234,7 +235,11 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
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),
|
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow),
|
||||||
tripMs: tripRealMs(model, origin, id, 0, now),
|
tripMs: tripRealMs(model, origin, id, 0, now),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.
|
- [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.10 — Art
|
||||||
- [ ] Step 10L.11 — Plan amendments and regression
|
- [ ] Step 10L.11 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,8 @@ export function contractTripGameHours(
|
||||||
* location. `discoveredIds` absent means every location counts.
|
* location. `discoveredIds` absent means every location counts.
|
||||||
*/
|
*/
|
||||||
export function wouldStrand(
|
export function wouldStrand(
|
||||||
model: EffectiveStats,
|
/** Effective stats, or a bare model (handling falls back to the game-wide base). */
|
||||||
|
model: ShipModel,
|
||||||
origin: Location,
|
origin: Location,
|
||||||
destination: Location,
|
destination: Location,
|
||||||
massTonnes: number,
|
massTonnes: number,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue