Fix: Contracts counts ready ships, not idle ships (decision 0.5.134)
CI / check (push) Successful in 6m59s
Details
CI / check (push) Successful in 6m59s
Details
A ship whose pilot was resting still showed on the location chips as available. The chips and the Jobs badge now count only ships a job could go out on now; the board names who wakes first when none is ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb9b0de5e4
commit
e17abb3a3e
|
|
@ -1772,3 +1772,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Decision 0.5.133: the qualifying fleet is the player's own.** The player can't send an Independent pilot, so jobs only that pilot could fly were noise on the board. They are left out of both generation and the read filter. Independent pilots aren't harmed: they choose with `validateDispatch` against their own pair, and when they have no fitting job they wait or head home as before.
|
||||
- **Fallback when no Manual pair exists**: the whole fleet counts, so a company whose only flyable ships are Independent still gets a board for them.
|
||||
- **An Independent pilot's ship stays out even when another pilot is spare**: it stays assigned to the Independent pilot, so pairing it with an unassigned pilot would offer jobs the player can't send without first taking that pilot off it.
|
||||
|
||||
## Fix: ready ships, not idle ships
|
||||
|
||||
- **Decision 0.5.134: the Contracts chips count ships ready for a job.** "Idle" counted ships whose pilot was resting, so a location looked like it had a pilot available when none could fly. Uncrewed ships, ships in the shop and Independent pilots' ships don't count either: none of them can take a job the player picks. The chip's label is now "N ship(s) ready".
|
||||
- **Rest is checked against the clock on the board**, not the `resting` flag from the last state fetch, so the count changes when rest ends. The Jobs tab badge keeps using the flag, to avoid re-rendering the whole app every second.
|
||||
- **The Dispatch sheet is unchanged**: it already lists a resting pilot's ship as blocked, with the time left.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { content, type Location, type ShipModel } from "@deadhead/content";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||
import {
|
||||
checkRoute,
|
||||
effectiveStats,
|
||||
isInShop,
|
||||
MS_PER_HOUR,
|
||||
routeDistance,
|
||||
travelTimeGameHours,
|
||||
|
|
@ -152,3 +153,39 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
|||
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an idle ship can't take a job now, or null when it can: no pilot, the
|
||||
* pilot resting, the ship in the shop, or an Independent pilot flying it on
|
||||
* their own (decision 0.5.134). Without `now`, rest is the server's `resting`
|
||||
* flag and the shop isn't checked.
|
||||
*/
|
||||
export function notReady(
|
||||
state: Pick<PlayerState, "pilots">,
|
||||
ship: Ship,
|
||||
now?: number,
|
||||
): { reason: "no-pilot" | "independent" | "in-shop" } | { reason: "resting"; pilot: Pilot } | null {
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
||||
if (!pilot) return { reason: "no-pilot" };
|
||||
if (pilot.mode === "independent") return { reason: "independent" };
|
||||
const resting =
|
||||
now === undefined ? pilot.resting : pilot.restEndsAt !== null && pilot.restEndsAt > now;
|
||||
if (resting) return { reason: "resting", pilot };
|
||||
if (now !== undefined && isInShop(ship, now)) return { reason: "in-shop" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ships docked at `locationId` (or anywhere) that a job could go out on now. */
|
||||
export function readyShips(
|
||||
state: Pick<PlayerState, "pilots" | "ships">,
|
||||
locationId?: string,
|
||||
now?: number,
|
||||
): Ship[] {
|
||||
return state.ships.filter(
|
||||
(s) =>
|
||||
s.status === "idle" &&
|
||||
s.locationId !== null &&
|
||||
(locationId === undefined || s.locationId === locationId) &&
|
||||
notReady(state, s, now) === null,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { readyShips } from "../game";
|
||||
import type { ScreenPath } from "../routes";
|
||||
|
||||
/**
|
||||
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
|
||||
* to claim, ships ready for a job, and unspent skill points across all pilots
|
||||
* (decision 0.5.109).
|
||||
* to claim, ships ready for a job (decision 0.5.134), and unspent skill points
|
||||
* across all pilots (decision 0.5.109).
|
||||
*/
|
||||
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
||||
const restingIds = new Set(state.pilots.filter((p) => p.resting).map((p) => p.id));
|
||||
const readyShips = state.tutorial
|
||||
? state.tutorial.runInFlight
|
||||
? 0
|
||||
: 1
|
||||
: state.ships.filter(
|
||||
(s) => s.status === "idle" && s.pilotId !== null && !restingIds.has(s.pilotId),
|
||||
).length;
|
||||
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
|
||||
return {
|
||||
"/": state.goals.filter((g) => g.status === "ready").length,
|
||||
"/contracts": readyShips,
|
||||
"/contracts": ready,
|
||||
"/pilots": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, notReady, readyShips } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { pairOptions } from "./DispatchSheet";
|
||||
import { DeadheadPicker } from "./Fleet";
|
||||
|
|
@ -89,8 +89,9 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
|||
const requested = params.get("location");
|
||||
const locationId =
|
||||
requested && discovered.includes(requested) ? requested : defaultBoardLocation(state);
|
||||
const idleAt = (id: string) =>
|
||||
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
||||
const now = useClock((s) => s.now);
|
||||
// Only ships a job could go out on now count (decision 0.5.134).
|
||||
const readyAt = (id: string) => readyShips(state, id, now).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -100,7 +101,7 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
|||
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{discovered.map((id) => {
|
||||
const idle = idleAt(id);
|
||||
const ready = readyAt(id);
|
||||
const sprite = content.location(id)?.sprite;
|
||||
return (
|
||||
<button
|
||||
|
|
@ -112,24 +113,19 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
|||
>
|
||||
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
||||
{locationName(id)}
|
||||
{idle > 0 && (
|
||||
{ready > 0 && (
|
||||
<span
|
||||
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
||||
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
|
||||
className="grid h-5 min-w-5 place-items-center rounded-full bg-cyan-400/20 px-1.5 text-xs text-cyan-200 tabular-nums ring-1 ring-cyan-400/40"
|
||||
>
|
||||
{idle}
|
||||
{ready}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{idleAt(locationId) === 0 && (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
|
||||
<Icon name="alert" className="size-4 shrink-0 text-amber-300" />
|
||||
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
||||
</p>
|
||||
)}
|
||||
{readyAt(locationId) === 0 && <NoneReady locationId={locationId} now={now} />}
|
||||
<LocationBoard locationId={locationId} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -298,6 +294,34 @@ function LocationBoard({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why no ship here can take a job now (decision 0.5.134): none docked, or the
|
||||
* pilots here resting (with who wakes first), or none crewed for the player.
|
||||
*/
|
||||
function NoneReady({ locationId, now }: { locationId: string; now: number }) {
|
||||
const state = useLoadedPlayer();
|
||||
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === locationId);
|
||||
const waking = idle
|
||||
.map((s) => notReady(state, s, now))
|
||||
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
|
||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
|
||||
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0 text-amber-300" />
|
||||
{idle.length === 0 ? (
|
||||
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
|
||||
) : waking ? (
|
||||
<span>
|
||||
No pilot here is ready to fly. {waking.name} is resting, ready in{" "}
|
||||
<Countdown to={waking.restEndsAt!} format={formatClock} done="a moment" />.
|
||||
</span>
|
||||
) : (
|
||||
"No ship here is ready for a job right now."
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle, crewed ships here that cannot take any contract on this board (CR-004):
|
||||
* offer to rest the pilot, who wakes to a new board, or to deadhead elsewhere.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Contract, Ship } from "@deadhead/schema";
|
||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -61,18 +61,28 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
/** Only an Earth bay; one idle ship at Earth, two at the Moon (the newest arrival). */
|
||||
const owner = () =>
|
||||
samplePlayerState({
|
||||
/** Only an Earth bay; one crewed idle ship at Earth, two at the Moon (the newest arrival). */
|
||||
const owner = () => {
|
||||
const base = samplePlayerState();
|
||||
const crew = (id: string, shipId: string, locationId: string): Pilot => ({
|
||||
...base.pilots[0]!,
|
||||
id,
|
||||
isAvatar: id === "avatar",
|
||||
assignedShipId: shipId,
|
||||
locationId,
|
||||
});
|
||||
return samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [
|
||||
ship("s1", { locationId: "earth", lastArrivalAt: NOW - 120_000 }),
|
||||
ship("s2", { locationId: "moon", lastArrivalAt: NOW - 1000 }),
|
||||
ship("s3", { locationId: "moon", lastArrivalAt: NOW - 90_000 }),
|
||||
ship("s1", { locationId: "earth", pilotId: "avatar", lastArrivalAt: NOW - 120_000 }),
|
||||
ship("s2", { locationId: "moon", pilotId: "p2", lastArrivalAt: NOW - 1000 }),
|
||||
ship("s3", { locationId: "moon", pilotId: "p3", lastArrivalAt: NOW - 90_000 }),
|
||||
ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }),
|
||||
],
|
||||
pilots: [crew("avatar", "s1", "earth"), crew("p2", "s2", "moon"), crew("p3", "s3", "moon")],
|
||||
});
|
||||
};
|
||||
|
||||
const boardApi = () =>
|
||||
fakeApi({
|
||||
|
|
@ -91,7 +101,7 @@ describe("location contract boards", () => {
|
|||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("lists every discovered location, none undiscovered, with idle-ship badges", async () => {
|
||||
it("lists every discovered location, none undiscovered, with ready-ship badges", async () => {
|
||||
renderApp(boardApi(), "/contracts");
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
const names = within(group)
|
||||
|
|
@ -99,10 +109,10 @@ describe("location contract boards", () => {
|
|||
.map((b) => b.textContent?.replace(/[0-9]+$/, ""));
|
||||
expect(names).toEqual(["Earth", "Moon"]);
|
||||
expect(within(group).getByRole("button", { name: /Earth/ })).toContainElement(
|
||||
within(group).getByLabelText("1 idle ship"),
|
||||
within(group).getByLabelText("1 ship ready"),
|
||||
);
|
||||
expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement(
|
||||
within(group).getByLabelText("2 idle ships"),
|
||||
within(group).getByLabelText("2 ships ready"),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { tabAttention } from "../src/layout/attention";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const ship = (id: string, pilotId: string): Ship => ({
|
||||
id,
|
||||
modelId: "courier",
|
||||
name: id,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: NOW - 60_000,
|
||||
...shipExtras("courier"),
|
||||
pilotId,
|
||||
});
|
||||
|
||||
const base = samplePlayerState();
|
||||
const pilot = (id: string, shipId: string, overrides: Partial<Pilot> = {}): Pilot => ({
|
||||
...base.pilots[0]!,
|
||||
id,
|
||||
name: id,
|
||||
isAvatar: id === "avatar",
|
||||
assignedShipId: shipId,
|
||||
locationId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const resting = { resting: true, restEndsAt: NOW + 2 * 60 * 60_000 };
|
||||
|
||||
/** Two ships at Earth: the avatar's and Rosa's, with the pilots as given. */
|
||||
const owner = (avatar: Partial<Pilot>, rosa: Partial<Pilot>): PlayerState =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [ship("s1", "avatar"), ship("s2", "rosa")],
|
||||
pilots: [pilot("avatar", "s1", avatar), pilot("Rosa", "s2", { id: "rosa", ...rosa })],
|
||||
});
|
||||
|
||||
const show = (state: PlayerState) =>
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => state,
|
||||
getContractBoard: () => ({ contracts: [], periodEndsAt: NOW + 30 * 60_000 }),
|
||||
}),
|
||||
"/contracts?location=earth",
|
||||
);
|
||||
|
||||
describe("ready ships on the Contracts screen (decision 0.5.134)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("doesn't count a ship whose pilot is resting", async () => {
|
||||
show(owner({}, resting));
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
const earth = within(group).getByRole("button", { name: /Earth/ });
|
||||
expect(within(earth).getByLabelText("1 ship ready")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("doesn't count an Independent pilot's ship", async () => {
|
||||
show(owner({}, { mode: "independent" }));
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
expect(within(group).getByLabelText("1 ship ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says who is resting and when they're ready when no pilot here can fly", async () => {
|
||||
show(owner(resting, { ...resting, restEndsAt: NOW + 30 * 60_000 }));
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
expect(within(group).queryByLabelText(/ready/)).not.toBeInTheDocument();
|
||||
const status = await screen.findByText(/No pilot here is ready to fly/);
|
||||
// The first to wake.
|
||||
expect(status).toHaveTextContent("Rosa is resting, ready in 30:00");
|
||||
expect(screen.queryByText(/No idle ships here/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts a pilot once their rest is over, without waiting for a new state", async () => {
|
||||
show(owner({ resting: true, restEndsAt: NOW - 1 }, resting));
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
expect(within(group).getByLabelText("1 ship ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves resting and Independent pilots' ships out of the Jobs badge", () => {
|
||||
expect(tabAttention(owner({}, {}))["/contracts"]).toBe(2);
|
||||
expect(tabAttention(owner({}, resting))["/contracts"]).toBe(1);
|
||||
expect(tabAttention(owner({}, { mode: "independent" }))["/contracts"]).toBe(1);
|
||||
expect(tabAttention(owner(resting, resting))["/contracts"]).toBe(0);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 712 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
|
|
@ -189,6 +189,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
131. **Welcome on every new company** (CR-022). Three welcome cards (what the game is, how it plays, where you start), with text in content, shown once per company right after it's created ("Start your company" or "Start a new company"), before the CR-001 tutorial intro card. The save records that this company's welcome was seen; saves from before CR-022 count as seen.
|
||||
132. **How to play** (CR-022). Settings → Help reopens the three cards any time, ending in "Close"; it doesn't change the saved flag.
|
||||
133. **Boards are for the player's pilots** (fix after CR-021). The qualifying fleet (0.5.115) counts only Manual pilots and the ships not assigned to an Independent pilot, so the player never sees jobs only an Independent pilot could fly; Independent pilots check each job against their own pair. With no Manual pair at all, the whole fleet counts.
|
||||
134. **Ready ships** (fix after CR-022). The Contracts screen's location chips and the Jobs tab badge count only ships a job could go out on now: idle, with an assigned pilot who isn't resting or Independent, and not in the shop. Where idle ships are docked but none is ready, the board says so and names the pilot who wakes first.
|
||||
|
||||
### 0.6 Translating change requests written for the server (CR-014 §7)
|
||||
|
||||
|
|
|
|||
|
|
@ -504,3 +504,4 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0
|
|||
## Fixes after Phase 22
|
||||
|
||||
- [x] **Boards ignore Independent pilots (decision 0.5.133).** Reported: with Rosa Independent, the board still showed jobs only that pilot could fly. The board's qualifying fleet now leaves out Independent pilots and their ships, both when a board is generated and when it's read, so a board already stored this period hides those jobs at once. Independent pilots still check each job against their own pair; with no Manual pair at all, the whole fleet counts. Tests (`packages/engine/test/completable-boards.test.ts`): heavy jobs vanish from the board when the Freighter's pilot goes Independent, this period and the next five; the fallback to the whole fleet.
|
||||
- [x] **Ready ships, not idle ships (decision 0.5.134).** Reported: a resting pilot still showed as available on the board. The location chips on Contracts counted every idle ship, so a ship whose pilot was resting still showed as ready. They now count ships ready for a job (`readyShips` in `apps/web/src/game.ts`): crewed, the pilot not resting (checked against the clock, so the count updates the moment rest ends) or Independent, and not in the shop. The Jobs tab badge uses the same rule. With idle ships here but none ready, the board says "No pilot here is ready to fly" and who wakes first. Tests (`apps/web/test/ready-ships.test.tsx`, `location-boards.test.tsx`). Screenshots: `docs/screenshots/fix-ready-ships-resting-{phone,desktop}.png`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue