diff --git a/DECISIONS.md b/DECISIONS.md index f86d438..15148c1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1978,3 +1978,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **CR-018's completability rule is CR-019's Step 18.1** in this repo: the hull reason is recorded there as a temporary one. - **The game design document is left to the designer** (CR-026 §9). + +## Fix: job length on the board + +- **A job's length depends on the ship**, so the board shows it for the player's fastest crewed ship that could take the job (the permanent checks: hold, range, landing, skills), preferring ships already at the job's origin, since those are the ones that would fly it. Its label says it's game time, and a tooltip says which ship it's for. +- **It's the whole trip in game hours** (travel plus loading and takeoff/landing), the figure the Priority window and duty use. diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index c412985..de8e204 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -1,7 +1,9 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; -import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; +import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { checkRoute, + contractTripGameHours, + permanentFailure, effectiveStats, isInShop, MS_PER_HOUR, @@ -190,3 +192,24 @@ export function readyShips( notReady(state, s, now) === null, ); } + +/** + * How long a job takes in game hours (the trip's travel and handling), for the + * player's fastest ship that could take it: one at the job's origin if any, + * else anywhere in the fleet. Null with no ship that could. + */ +export function jobGameHours( + state: Pick, + contract: Contract, +): number | null { + const able = state.ships.flatMap((ship) => { + const pilot = state.pilots.find((p) => p.assignedShipId === ship.id); + if (!pilot) return []; + const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades }; + if (permanentFailure(contract, member, undefined, content) !== null) return []; + return [{ ship, hours: contractTripGameHours(shipStatsOf(ship), contract, content) }]; + }); + const here = able.filter((a) => a.ship.locationId === contract.originId); + const pool = here.length > 0 ? here : able; + return pool.length > 0 ? Math.min(...pool.map((a) => a.hours)) : null; +} diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index ec10ba6..dfbe4a1 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -5,8 +5,15 @@ import { useSearchParams } from "react-router"; 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, notReady, readyShips } from "../game"; +import { + formatClock, + formatCredits, + formatGameHours, + formatMillionKm, + locationName, + skillName, +} from "../format"; +import { distanceAt, jobGameHours, notReady, readyShips } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { pairOptions } from "./DispatchSheet"; import { DeadheadPicker } from "./Fleet"; @@ -271,6 +278,7 @@ function LocationBoard({ {contract.shipper} · {contract.massTonnes} t ·{" "} {formatMillionKm(distanceAt(contract.originId, contract.destinationId))} +
@@ -294,6 +302,22 @@ function LocationBoard({ ); } +/** " · 5.4 h": the job's length in game hours for the fastest ship that could take it. */ +function TripHours({ contract }: { contract: Contract }) { + const state = useLoadedPlayer(); + const hours = jobGameHours(state, contract); + if (hours === null) return null; + return ( + + {" "} + · {formatGameHours(hours)} + + ); +} + /** * 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. diff --git a/apps/web/test/job-hours.test.tsx b/apps/web/test/job-hours.test.tsx new file mode 100644 index 0000000..d8074f5 --- /dev/null +++ b/apps/web/test/job-hours.test.tsx @@ -0,0 +1,108 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, Ship } from "@deadhead/schema"; +import { contractTripGameHours, effectiveStats } from "@deadhead/sim"; +import { screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { formatGameHours } from "../src/format"; +import { jobGameHours } from "../src/game"; +import { pilotExtras } from "./fixtures"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const job = (over: Partial = {}): Contract => ({ + id: "j1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 12, + payout: 1_500, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 30 * 60_000, + firstContact: false, + ...over, +}); + +const ship = (id: string, modelId: string, locationId: string, pilotId: string | null): Ship => ({ + id, + modelId, + name: id, + status: "idle", + locationId, + dispatchId: null, + pilotId, + lastArrivalAt: 0, + ...shipExtras(modelId), +}); +const pilot = (id: string, shipId: string): Pilot => ({ + ...samplePlayerState().pilots[0]!, + id, + name: id, + isAvatar: id === "avatar", + assignedShipId: shipId, + locationId: null, + ...pilotExtras, +}); + +const hoursFor = (modelId: string, contract: Contract) => + contractTripGameHours( + effectiveStats(content.shipModel(modelId)!, {}, content), + contract, + content, + ); + +describe("job length on the board", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows the game hours beside the weight and distance", async () => { + const contract = job(); + const state = samplePlayerState({ + tutorial: null, + bays: [{ id: "b", locationId: "earth" }], + ships: [ship("s1", "courier", "earth", "avatar")], + pilots: [pilot("avatar", "s1")], + }); + renderApp( + fakeApi({ + getPlayerState: () => state, + getContractBoard: () => ({ contracts: [contract], periodEndsAt: NOW + 30 * 60_000 }), + }), + "/contracts?location=earth", + ); + const hours = formatGameHours(hoursFor("courier", contract)); + const time = await screen.findByLabelText(`Takes ${hours} of game time`); + const line = time.parentElement!; + expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`); + expect(within(line).getByText(/12 t/)).toBeInTheDocument(); + }); + + it("times the job for the fastest ship that could take it, preferring one at the origin", () => { + const contract = job(); + const ships = [ + ship("slow-here", "freighter", "earth", "p1"), + ship("fast-away", "clipper", "moon", "p2"), + ]; + const pilots = [pilot("p1", "slow-here"), pilot("p2", "fast-away")]; + expect(jobGameHours({ ships, pilots }, contract)).toBeCloseTo(hoursFor("freighter", contract)); + // Nobody at Earth: the fastest anywhere. + const away = ships.map((s) => ({ ...s, locationId: "moon" })); + expect(jobGameHours({ ships: away, pilots }, contract)).toBeCloseTo( + Math.min(hoursFor("freighter", contract), hoursFor("clipper", contract)), + ); + // No crewed ship that could take it: no time shown. + expect(jobGameHours({ ships, pilots: [] }, contract)).toBeNull(); + }); +}); diff --git a/docs/screenshots/fix-job-hours-desktop.png b/docs/screenshots/fix-job-hours-desktop.png new file mode 100644 index 0000000..5c71801 Binary files /dev/null and b/docs/screenshots/fix-job-hours-desktop.png differ diff --git a/docs/screenshots/fix-job-hours-phone.png b/docs/screenshots/fix-job-hours-phone.png new file mode 100644 index 0000000..fae7cc8 Binary files /dev/null and b/docs/screenshots/fix-job-hours-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index bd8c98d..b2bbfab 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -559,3 +559,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C - [x] **Step 26.10 — Plan amendments and regression.** CR-026 §9 applied: plan 0.3 (random events no longer deferred), 0.5 (0.5.143–0.5.152, recorded with the CR and in Step 26.9), Steps 3.13 and 3.17; CR-009 Step 10F.1 (Hull Plating); CR-014 Steps 10K.6 (events in the loop and catch-up) and 10K.9 (radio-call notifications); CR-019 Step 18.1 (the hull reason isn't a hidden-job rule); CR-021 Step 19.4 (Independent pilots get events); CR-024 Step 22.2 (answer and repair are manual decisions); CR-025 Step 23.2 (event highlights). `game-design-document.md` §13 is the designer's to update. Full suite: typecheck, lint, 1564 unit tests and 11 e2e tests pass. **Phase 26 is complete; stopped for review.** Manual check for the user: on a fresh company, fly past the deal and three more trips, send a ship on a long contract (6+ game hours), and check the first event is the Derelict cargo pod radio call; answer it; later let a radio call run out and check the default applied (its result in the summary and the Logbook); use Developer tools → Damage hull on a ship, then repair it at an HQ from its Upgrades sheet. + +## Fixes after Phase 26 + +- [x] **Job length on the board.** Location board cards show how long the job takes in game hours beside the weight and distance ("Atlas Heavy Industries · 6 t · 0.38M km · 2.2 h"), for the fastest of the player's crewed ships that could take it, preferring one at the job's origin (`jobGameHours` in `apps/web/src/game.ts`); none is shown if no ship could. The tutorial board already showed its trip time. Tests: `apps/web/test/job-hours.test.tsx`. Screenshots: `docs/screenshots/fix-job-hours-{phone,desktop}.png`.