diff --git a/DECISIONS.md b/DECISIONS.md index 88785f8..401dcfb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -976,3 +976,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 10I.8 + +- **"Finished since the last visit" means finished and not yet acknowledged**: a ship keeps its shop job after the shop time passes until the player dismisses the modal, so an upgrade that finished while the app was closed is reported once, like a delivery. +- **Acknowledging only clears finished jobs**; a ship still in the shop keeps its job even if its id is sent. +- **The modal can open for shop news alone** (no deliveries), and the acknowledged count includes cleared upgrades. +- **The HQ card only appears while a ship is in the shop**, unlike the always-on pilot rest card: the shop is an occasional action, rest is a rhythm. diff --git a/apps/server/src/game/summaries.ts b/apps/server/src/game/summaries.ts index 57a807e..fb111c5 100644 --- a/apps/server/src/game/summaries.ts +++ b/apps/server/src/game/summaries.ts @@ -1,7 +1,7 @@ import type { RealTime } from "@deadhead/sim"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm"; import type { Queryable } from "../db/client"; -import { dispatches } from "../db/schema"; +import { dispatches, ships } from "../db/schema"; /** * Marks "while you were away" summaries as seen (Step 6.13). Only this @@ -28,3 +28,29 @@ export async function acknowledgeSummaries( .returning({ id: dispatches.id }); return updated.length; } + +/** + * Clears finished upgrades the player has now seen ("out of the shop", CR-009). + * Ships still in the shop keep their job. + */ +export async function acknowledgeFinishedUpgrades( + tx: Queryable, + playerId: string, + shipIds: string[], + now: RealTime, +): Promise { + if (shipIds.length === 0) return 0; + const cleared = await tx + .update(ships) + .set({ shopUpgradeId: null, shopLevel: null }) + .where( + and( + eq(ships.playerId, playerId), + inArray(ships.id, shipIds), + isNotNull(ships.shopUpgradeId), + lte(ships.inShopUntil, now), + ), + ) + .returning({ id: ships.id }); + return cleared.length; +} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index b00ff84..51ff558 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -9,7 +9,7 @@ import { claimGoal } from "../game/goals"; import { repayLoan } from "../game/loan"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; -import { acknowledgeSummaries } from "../game/summaries"; +import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries"; import { ApiError } from "../http/errors"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; @@ -49,7 +49,9 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) => withPlayer(ctx, playerId, async (tx, now) => ({ - acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now), + acknowledged: + (await acknowledgeSummaries(tx, playerId, input.dispatchIds, now)) + + (await acknowledgeFinishedUpgrades(tx, playerId, input.shipIds ?? [], now)), })), ); diff --git a/apps/server/test/upgrades.test.ts b/apps/server/test/upgrades.test.ts index 36d7ce5..8ee2605 100644 --- a/apps/server/test/upgrades.test.ts +++ b/apps/server/test/upgrades.test.ts @@ -160,4 +160,27 @@ describe("starting an upgrade (Step 10I.5)", () => { expect(state.company.credits).toBe(cost / 2); expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1); }); + + it("keeps the finished job until acknowledged, and only clears finished ones", async () => { + const { app, clock } = testApp(); + const { owner, freighter } = await ownerWithFreighter(); + await upgrade(owner, freighter.id, "cargo-bay"); + const ack = () => + call(app, owner, "POST", "/api/summaries/acknowledge", { + dispatchIds: [], + shipIds: [freighter.id], + }); + + // Still in the shop: acknowledging does nothing. + expect((await ack()).json()).toEqual({ acknowledged: 0 }); + const until = shipIn(await getState(app, owner), freighter.id).shop!.until; + + clock.now = until; + expect(shipIn(await getState(app, owner), freighter.id)).toMatchObject({ + inShopUntil: null, + shop: { upgradeId: "cargo-bay", level: 1, until }, + }); + expect((await ack()).json()).toEqual({ acknowledged: 1 }); + expect(shipIn(await getState(app, owner), freighter.id).shop).toBeNull(); + }); }); diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index 717a20c..2e76ade 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -1,6 +1,7 @@ import { content } from "@deadhead/content"; import { useState } from "react"; import { useNavigate } from "react-router"; +import { useClock } from "../clock/clock"; import { formatCredits, locationName, toLocation } from "../format"; import { formatLocalTime } from "../screens/DispatchSheet"; import { useGameAction, useLoadedPlayer } from "../state/actions"; @@ -14,21 +15,27 @@ export function AwaySummary() { const [dismissed, setDismissed] = useState>(new Set()); const navigate = useNavigate(); + const now = useClock((s) => s.now); const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); + // Upgrades finished and not yet seen: "out of the shop" (CR-009). + const outOfShop = state.ships.filter( + (s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`), + ); // Goals waiting to be claimed (Step 10A.17). const readyGoals = state.goals .filter((g) => g.status === "ready") .map((g) => content.onboardingGoal(g.id)) .filter((g) => g !== undefined); - if (summaries.length === 0) return null; + if (summaries.length === 0 && outOfShop.length === 0) return null; const shipName = (shipId: string, tutorial: boolean) => tutorial ? "Company loaner" : (state.ships.find((s) => s.id === shipId)?.name ?? "A ship"); const dismiss = () => { const ids = summaries.map((s) => s.dispatchId); - setDismissed(new Set([...dismissed, ...ids])); - acknowledge.mutate({ dispatchIds: ids }); + const shipIds = outOfShop.map((s) => s.id); + setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)])); + acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) }); }; return ( @@ -98,6 +105,16 @@ export function AwaySummary() { )} ))} + {outOfShop.map((ship) => ( +
  • + + {ship.name} is out of the shop:{" "} + {content.upgrade(ship.shop!.upgradeId)?.name ?? ship.shop!.upgradeId} {ship.shop!.level} +
  • + ))} {readyGoals.length > 0 && (
    diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx index 0c4343d..1442f26 100644 --- a/apps/web/src/screens/HQ.tsx +++ b/apps/web/src/screens/HQ.tsx @@ -35,6 +35,10 @@ export function HQ() { const resting = state.pilots .filter((p) => p.resting && p.restEndsAt !== null) .sort((a, b) => a.restEndsAt! - b.restEndsAt!); + // Ships in the upgrade shop, soonest out first (CR-009). + const inShop = state.ships + .filter((s) => s.inShopUntil !== null && now < s.inShopUntil) + .sort((a, b) => a.inShopUntil! - b.inShopUntil!); const nextShip = next?.tutorial ? "ship-company-courier" : content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite; @@ -135,6 +139,22 @@ export function HQ() { )} + {inShop.length > 0 && ( + +

    + In the shop: {inShop.length} +

    +
      + {inShop.map((ship) => ( +
    • + {ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "} + {ship.shop?.level} · ready in +
    • + ))} +
    +
    + )} + {!state.tutorial && state.ships.length === 0 && (

    No ships yet — buy one in the Market.

    )} diff --git a/apps/web/test/shop-summary-hq.test.tsx b/apps/web/test/shop-summary-hq.test.tsx new file mode 100644 index 0000000..4eb737b --- /dev/null +++ b/apps/web/test/shop-summary-hq.test.tsx @@ -0,0 +1,91 @@ +import type { Ship } from "@deadhead/schema"; +import { screen, waitFor, 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"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, name: string, overrides: Partial): Ship => ({ + id, + name, + modelId: "freighter", + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: null, + lastArrivalAt: 0, + ...shipExtras("freighter"), + ...overrides, +}); + +describe("the shop in summaries and on HQ (Step 10I.8)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => { + const api = fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + ships: [ + ship("s1", "Slow Ox", { + upgrades: { engines: 2 }, + shop: { upgradeId: "engines", level: 2, until: NOW - 60_000 }, + }), + ship("s2", "Busy Ox", { + upgrades: { "cargo-bay": 1 }, + inShopUntil: NOW + 60_000, + shop: { upgradeId: "cargo-bay", level: 1, until: NOW + 60_000 }, + }), + ], + }), + }); + renderApp(api, "/"); + const dialog = await screen.findByRole("dialog", { name: "While you were away" }); + expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning 2"); + expect(dialog).not.toHaveTextContent("Busy Ox"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Got it" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { + dispatchIds: [], + shipIds: ["s1"], + }), + ); + }); + + it("lists ships in the shop on HQ, soonest first, with countdowns", async () => { + renderApp( + fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + ships: [ + ship("s1", "Late Ox", { + inShopUntil: NOW + 25 * 60_000, + shop: { upgradeId: "engines", level: 2, until: NOW + 25 * 60_000 }, + }), + ship("s2", "Soon Ox", { + inShopUntil: NOW + 4 * 60_000, + shop: { upgradeId: "cargo-bay", level: 1, until: NOW + 4 * 60_000 }, + }), + ship("s3", "Free Ox", {}), + ], + }), + }), + "/", + ); + const card = await screen.findByLabelText("In the shop"); + expect(card).toHaveTextContent("In the shop: 2"); + const [first, second] = within(card).getAllByRole("listitem"); + expect(first).toHaveTextContent("Soon Ox · Cargo Bay Expansion 1 · ready in 4m 00s"); + expect(second).toHaveTextContent("Late Ox · Engine Tuning 2 · ready in 25m 00s"); + expect(card).not.toHaveTextContent("Free Ox"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7b1b33f..bfc210b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -233,5 +233,5 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 - [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`. - [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 +- [x] **Step 10I.8 — Client: shop in summaries and on HQ.** The "While you were away" modal also opens for finished, unseen upgrades and lists "[Ship] is out of the shop: Engine Tuning 2"; "Got it" sends their `shipIds` with `acknowledgeSummaries`, and the server (`acknowledgeFinishedUpgrades`) clears only jobs whose shop time has passed. HQ has an "In the shop" card (when any) listing each ship, its upgrade and level, and a countdown, soonest first. Tests: `shop-summary-hq.test.tsx`, and the acknowledge case in `upgrades.test.ts`. - [ ] Step 10I.9 — Plan amendments and regression