diff --git a/DECISIONS.md b/DECISIONS.md index 7308224..66e1dbc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1722,3 +1722,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"Anywhere you know" is `destinationIds: null`**, so places discovered later are included. Unticking it starts from every place known now. - **The Fleet badge sits on the pilot line**: beside the status pill on a phone it truncated the ship's name. - **The spending floor's button is "Save floor"**, distinct from the company name's "Save". + +## Step 21.7 + +- **The "Yours for" chip appears only when a pilot is Independent**: without one, nobody competes for the job and the chip would be noise. First contacts never get it; they're always the player's. +- **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one. +- **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions. +- **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume. diff --git a/apps/web/src/GameRoot.tsx b/apps/web/src/GameRoot.tsx index 4741aad..4625645 100644 --- a/apps/web/src/GameRoot.tsx +++ b/apps/web/src/GameRoot.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { createEngineClient } from "./api/engine-client"; import { ApiProvider } from "./api/context"; +import { CatchingUp } from "./components/PilotLogbook"; import { GameContext } from "./platform/context"; import { startResolutionLoop, type Game } from "./platform/game"; import { PLAYER_STATE_KEY } from "./state/player"; @@ -26,6 +27,8 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode } const queryClient = useQueryClient(); const api = useMemo(() => createEngineClient(game.engine), [game]); const [status, setStatus] = useState("starting"); + // A slow catch-up of Independent pilots shows "Catching up on your fleet…" (CR-021). + const [catchingUp, setCatchingUp] = useState(false); useEffect(() => { game.lock.onLost(() => setStatus("elsewhere")); @@ -40,10 +43,14 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode } useEffect(() => { if (status !== "here") return; - const loop = startResolutionLoop(game, () => { - void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); - void queryClient.invalidateQueries({ queryKey: ["board"] }); - }); + const loop = startResolutionLoop( + game, + () => { + void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); + void queryClient.invalidateQueries({ queryKey: ["board"] }); + }, + setCatchingUp, + ); if (game.devTools) { window.deadhead = { call: (name, input) => game.engine.call(name, input as never) }; } @@ -77,6 +84,7 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode } return ( {children} + ); } diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index fd4f91d..699b504 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -7,6 +7,7 @@ import { formatCredits, locationName, toLocation } from "../format"; import { formatLocalTime } from "../screens/DispatchSheet"; import { useGameAction, useLoadedPlayer } from "../state/actions"; import { Button, Icon, Sheet } from "../ui"; +import { PilotLogbook, pilotLogbook } from "./PilotLogbook"; /** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ export function AwaySummary() { @@ -39,10 +40,15 @@ export function AwaySummary() { acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) }); }; + // Independent pilots' own trips (CR-021) are listed by pilot, not one card each. + const logbook = pilotLogbook(state, summaries); + const yours = summaries.filter((s) => !s.independent); + return ( +
    - {summaries.map((s) => ( + {yours.map((s) => (
  • s.independent).sort((a, b) => a.resolvedAt - b.resolvedAt); + const pilots: PilotLog[] = []; + for (const pilot of state.pilots) { + const trips = own.filter((s) => s.pilotId === pilot.id); + if (trips.length === 0) continue; + const entries: LogEntry[] = []; + for (const s of trips) { + if (s.deadhead) { + entries.push({ + kind: "deadhead", + dispatchId: s.dispatchId, + originId: s.originId, + destinationId: s.destinationId, + fuelCost: s.fuelCost, + }); + } else { + entries.push({ + kind: "job", + dispatchId: s.dispatchId, + good: (s.goodId && content.good(s.goodId)?.name) || "Cargo", + originId: s.originId, + destinationId: s.destinationId, + payout: s.credits + s.pilotPay + s.loanRepayment, + pilotShare: s.pilotPay, + fuelCost: s.fuelCost, + xp: s.pilotXp, + }); + } + if (s.restEndsAt !== null) { + entries.push({ kind: "rest", dispatchId: s.dispatchId, until: s.restEndsAt }); + } + } + pilots.push({ + pilotId: pilot.id, + name: pilot.name, + entries, + xp: trips.reduce((sum, s) => sum + s.pilotXp, 0), + now: + independentStatus(state, pilot) ?? + (pilot.locationId ? `At ${locationName(pilot.locationId)}` : "In flight"), + }); + } + const all = pilots.flatMap((p) => p.entries); + const jobs = all.filter((e) => e.kind === "job"); + const deadheads = all.filter((e) => e.kind === "deadhead"); + return { + totals: { + jobs: jobs.length, + deadheads: deadheads.length, + payout: jobs.reduce((sum, e) => sum + e.payout, 0), + pilotShare: jobs.reduce((sum, e) => sum + e.pilotShare, 0), + fuelCost: [...jobs, ...deadheads].reduce((sum, e) => sum + e.fuelCost, 0), + xp: pilots.reduce((sum, p) => sum + p.xp, 0), + }, + pilots, + }; +} + +/** The pilot logbook on the "while you were away" sheet. */ +export function PilotLogbook({ logbook }: { logbook: Logbook }) { + if (logbook.pilots.length === 0) return null; + const { totals } = logbook; + return ( +
    +
    + {totals.jobs} jobs + {totals.deadheads} deadheads + {formatCredits(totals.payout)} paid + {formatCredits(totals.pilotShare)} pilot shares + {formatCredits(totals.fuelCost)} fuel + +{totals.xp} XP +
    + {logbook.pilots.map((pilot) => ( +
    + + {pilot.name} + +{pilot.xp} XP + +

    + {pilot.now} +

    +
      + {pilot.entries.map((e) => ( +
    • + {e.kind === "job" && ( + <> + {e.good} + + {locationName(e.originId)} → {locationName(e.destinationId)} + + {formatCredits(e.payout)} + share {formatCredits(e.pilotShare)} + fuel {formatCredits(e.fuelCost)} + + )} + {e.kind === "deadhead" && ( + <> + + + Deadhead {locationName(e.originId)} → {locationName(e.destinationId)} + + fuel {formatCredits(e.fuelCost)} + + )} + {e.kind === "rest" && ( + + + Rested until {formatLocalTime(e.until)} + + )} +
    • + ))} +
    +
    + ))} +
    + ); +} + +/** + * "Catching up on your fleet…" (CR-021): shown only when a catch-up has been + * running for more than `delayMs`, so quick passes never flash it. + */ +export function CatchingUp({ busy, delayMs = 500 }: { busy: boolean; delayMs?: number }) { + const [shown, setShown] = useState(false); + useEffect(() => { + if (!busy) { + setShown(false); + return; + } + const timer = setTimeout(() => setShown(true), delayMs); + return () => clearTimeout(timer); + }, [busy, delayMs]); + if (!shown) return null; + return ( +

    + Catching up on your fleet… +

    + ); +} diff --git a/apps/web/src/platform/game.ts b/apps/web/src/platform/game.ts index e5b95a3..604f353 100644 --- a/apps/web/src/platform/game.ts +++ b/apps/web/src/platform/game.ts @@ -4,6 +4,7 @@ import { IndexedDbBackend, MemoryBackend, NotificationSync, + RESOLUTION_INTERVAL_MS, ResolutionLoop, SaveStorage, TabLock, @@ -88,8 +89,18 @@ export const browserLoopHost: LoopHost = { }, }; -export function startResolutionLoop(game: Game, onChange: () => void): ResolutionLoop { - const loop = new ResolutionLoop(game.engine, browserLoopHost, onChange); +export function startResolutionLoop( + game: Game, + onChange: () => void, + onBusy?: (busy: boolean) => void, +): ResolutionLoop { + const loop = new ResolutionLoop( + game.engine, + browserLoopHost, + onChange, + RESOLUTION_INTERVAL_MS, + onBusy, + ); void loop.start(); return loop; } diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index b26ed6e..e299888 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -143,7 +143,11 @@ function LocationBoard({ onSelect?: (contract: Contract) => void; }) { const api = useApi(); + const state = useLoadedPlayer(); const now = useClock((s) => s.now); + // Jobs are the player's alone for the first minutes of a board (decision 0.5.126). + const pilotsOnTheirOwn = state.pilots.some((p) => p.mode === "independent"); + const firstLookMs = content.config.independentPilots.firstLookRealMinutes * 60_000; const board = useQuery({ queryKey: locationBoardKey(locationId), queryFn: () => api.call("getContractBoard", { locationId }), @@ -245,6 +249,20 @@ function LocationBoard({ First contact )} + {pilotsOnTheirOwn && + !contract.firstContact && + now < contract.offeredAt + firstLookMs && ( + + + Yours for{" "} + + + + )} { dispatchId: "d3", destinationId: "moon", deadhead: true, + independent: false, fuelCost: 230, topUp: null, goodId: null, diff --git a/apps/web/test/board-logbook.test.tsx b/apps/web/test/board-logbook.test.tsx new file mode 100644 index 0000000..f0206b6 --- /dev/null +++ b/apps/web/test/board-logbook.test.tsx @@ -0,0 +1,239 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, PlayerState, ResolutionSummary } from "@deadhead/schema"; +import { act, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook"; +import { formatCredits } from "../src/format"; +import { pilotExtras } from "./fixtures"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const PERIOD_START = Date.parse("2100-01-01T00:00:00Z"); +const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * 60_000; + +const job = (over: Partial = {}): Contract => ({ + id: "earth:1:0", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 6, + payout: 1_234, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: PERIOD_START, + expiresAt: PERIOD_START + 600_000, + firstContact: false, + ...over, +}); + +const pilot = (id: string, name: string, over: Partial = {}): Pilot => ({ + id, + name, + isAvatar: false, + isVeteran: false, + xp: 0, + level: 0, + skills: {}, + skillPointsAvailable: 0, + assignedShipId: `ship-${id}`, + status: "idle", + locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, + ...pilotExtras, + mode: "independent", + ...over, +}); + +function owner(pilots: Pilot[]): PlayerState { + const base = samplePlayerState({ tutorial: null }); + return { + ...base, + bays: [{ id: "b", locationId: "earth" }], + pilots: [base.pilots[0]!, ...pilots], + ships: pilots.map((p) => ({ + id: `ship-${p.id}`, + modelId: "courier", + name: `Ship ${p.id}`, + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + ...shipExtras("courier"), + pilotId: p.id, + })), + dispatchOffice: { owned: true, spendingFloor: 500 }, + }; +} + +describe("first-look chip (Step 21.7, decision 0.5.126)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(PERIOD_START + 29_000); + useClock.setState({ offsetMs: 0, synced: true, now: PERIOD_START + 29_000 }); + }); + afterEach(() => vi.useRealTimers()); + + const show = (state: PlayerState, contracts: Contract[]) => + renderApp( + fakeApi({ + getPlayerState: () => state, + getContractBoard: () => ({ contracts, periodEndsAt: PERIOD_START + 600_000 }), + }), + "/contracts?location=earth", + ); + + it("counts down the player's first look, then disappears", async () => { + show(owner([pilot("p1", "Ada")]), [job()]); + const card = (await screen.findByText("1,234 cr")).closest("li")!; + expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31"); + + act(() => { + vi.setSystemTime(PERIOD_START + FIRST_LOOK + 1_000); + useClock.setState({ now: PERIOD_START + FIRST_LOOK + 1_000 }); + }); + expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); + }); + + it("isn't shown without Independent pilots", async () => { + show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]); + const card = (await screen.findByText("1,234 cr")).closest("li")!; + expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); + }); + + it("isn't shown on a first contact, which is always the player's", async () => { + show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]); + const card = (await screen.findByText("1,234 cr")).closest("li")!; + expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); + }); +}); + +describe("catching up indicator (Step 21.7)", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("appears only when a catch-up runs longer than half a second", () => { + const { rerender } = render(); + act(() => vi.advanceTimersByTime(400)); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(200)); + expect(screen.getByRole("status")).toHaveTextContent("Catching up on your fleet…"); + rerender(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + + // A quick pass never shows it. + rerender(); + act(() => vi.advanceTimersByTime(300)); + rerender(); + act(() => vi.advanceTimersByTime(1_000)); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); + +describe("pilot logbook (Step 21.7)", () => { + const summary = (over: Partial): ResolutionSummary => ({ + dispatchId: "d", + shipId: "ship-p1", + pilotId: "p1", + originId: "earth", + destinationId: "moon", + tutorial: false, + independent: true, + deadhead: false, + fuelCost: 100, + topUp: null, + goodId: "consumer-goods", + massTonnes: 6, + shipper: "Pacific Orbital Logistics", + credits: 600, + loanRepayment: 200, + pilotPay: 200, + pilotXp: 10, + discoveredLocationId: null, + scriptedId: null, + restEndsAt: null, + resolvedAt: PERIOD_START, + ...over, + }); + const ada = pilot("p1", "Ada"); + const bo = pilot("p2", "Bo", { locationId: "moon" }); + const summaries = [ + summary({ dispatchId: "a1", resolvedAt: 1 }), + summary({ + dispatchId: "a2", + deadhead: true, + goodId: null, + massTonnes: null, + shipper: null, + credits: 0, + loanRepayment: 0, + pilotPay: 0, + pilotXp: 0, + fuelCost: 40, + resolvedAt: 2, + }), + summary({ + dispatchId: "b1", + pilotId: "p2", + shipId: "ship-p2", + destinationId: "earth", + originId: "moon", + credits: 900, + pilotPay: 300, + loanRepayment: 0, + pilotXp: 12, + restEndsAt: PERIOD_START + 3_600_000, + resolvedAt: 3, + }), + // The player's own delivery isn't in the pilots' logbook. + summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }), + ]; + + it("lists each pilot's jobs, deadheads, rests and XP, and where they are now", () => { + const book = pilotLogbook(owner([ada, bo]), summaries); + expect(book.pilots.map((p) => p.name)).toEqual(["Ada", "Bo"]); + const [a, b] = book.pilots; + expect(a!.entries.map((e) => e.kind)).toEqual(["job", "deadhead"]); + expect(a!.entries[0]).toMatchObject({ good: "Consumer Goods", payout: 1_000, pilotShare: 200 }); + expect(a!.xp).toBe(10); + expect(a!.now).toBe("Waiting for new contracts at Earth"); + expect(b!.entries.map((e) => e.kind)).toEqual(["job", "rest"]); + expect(b!.now).toBe("Waiting for new contracts at Moon"); + }); + + it("totals the entries for the company", () => { + const book = pilotLogbook(owner([ada, bo]), summaries); + const entries = book.pilots.flatMap((p) => p.entries); + const jobs = entries.filter((e) => e.kind === "job"); + expect(book.totals).toEqual({ + jobs: 2, + deadheads: 1, + payout: jobs.reduce((s, e) => s + (e.kind === "job" ? e.payout : 0), 0), + pilotShare: 500, + fuelCost: 100 + 40 + 100, + xp: 22, + }); + expect(book.totals.payout).toBe(1_000 + 1_200); + }); + + it("shows on the while-you-were-away sheet, with the player's own trip as a card", async () => { + const state = { ...owner([ada, bo]), summaries }; + renderApp(fakeApi({ getPlayerState: () => state }), "/"); + const book = await screen.findByLabelText("Pilot logbook"); + expect(within(book).getByLabelText("Company totals")).toHaveTextContent( + `2 jobs1 deadheads${formatCredits(2_200)} paid`, + ); + expect(within(book).getByLabelText("Ada log")).toHaveTextContent("Deadhead Earth → Moon"); + expect(within(book).getByLabelText("Bo log")).toHaveTextContent("Rested until"); + // One card for the player's own delivery, outside the logbook. + const sheet = screen.getByRole("dialog", { name: "While you were away" }); + expect(within(sheet).getAllByText("Earth → Moon").length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/test/dashboard.test.tsx b/apps/web/test/dashboard.test.tsx index 389fd7e..46713f8 100644 --- a/apps/web/test/dashboard.test.tsx +++ b/apps/web/test/dashboard.test.tsx @@ -152,6 +152,7 @@ describe("loan on HQ", () => { tutorial: false, credits: 538, deadhead: false, + independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods", diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index cbcd24c..fbd8244 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -102,6 +102,7 @@ describe("the deal", () => { tutorial: true, credits: 96, deadhead: false, + independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods", diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index a9972c8..9453388 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -65,6 +65,7 @@ const summary: ResolutionSummary = { originId: "earth", destinationId: "moon", tutorial: false, + independent: false, deadhead: false, fuelCost: 150, topUp: null, diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index 01e59f2..e4ceac2 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -37,6 +37,7 @@ const summary = (extra: Partial): ResolutionSummary => ({ tutorial: false, credits: 500, deadhead: false, + independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods", diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 6f60be1..3f1ca0d 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -143,6 +143,7 @@ describe("scripted delivery summary", () => { tutorial: true, credits: 461, deadhead: false, + independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods", diff --git a/docs/screenshots/21-7-first-look-desktop.png b/docs/screenshots/21-7-first-look-desktop.png new file mode 100644 index 0000000..c30e49c Binary files /dev/null and b/docs/screenshots/21-7-first-look-desktop.png differ diff --git a/docs/screenshots/21-7-logbook-desktop.png b/docs/screenshots/21-7-logbook-desktop.png new file mode 100644 index 0000000..c8329f9 Binary files /dev/null and b/docs/screenshots/21-7-logbook-desktop.png differ diff --git a/docs/screenshots/21-7-logbook-phone.png b/docs/screenshots/21-7-logbook-phone.png new file mode 100644 index 0000000..bb22f25 Binary files /dev/null and b/docs/screenshots/21-7-logbook-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 57466d8..af390bd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -474,6 +474,11 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C - **`IndependentBadge`** on Fleet cards, beside the pilot's name. Screenshots: `docs/screenshots/21-6-dispatch-office-desktop.png`, `21-6-dispatch-office-owned-phone.png`, `21-6-pilot-orders-phone.png`, `21-6-fleet-badge-phone.png`. Tests (`apps/web/test/independent-controls.test.tsx`): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor. -- [ ] Step 21.7 — Client: board and logbook +- [x] **Step 21.7 — Client: board and logbook.** + - **First-look chip.** Contract cards show "Yours for mm:ss" until the first-look window ends. It appears only when some pilot is Independent, and never on first contacts. + - **Catch-up indicator.** "Catching up on your fleet…" (`CatchingUp` in `apps/web/src/components/PilotLogbook.tsx`) appears when the loop's pass runs longer than 0.5 s. `ResolutionLoop` takes an `onBusy` callback. + - **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards. + + Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`. - [ ] Step 21.8 — Balance - [ ] Step 21.9 — Plan amendments and regression diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index e3f408c..950fb00 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -258,6 +258,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState { originId: d.record.originId, destinationId: d.record.destinationId, tutorial: d.tutorial, + independent: d.independent, deadhead: d.record.contract === null, fuelCost: d.record.fuelCost, topUp: d.resolution?.topUp diff --git a/packages/engine/src/loop.ts b/packages/engine/src/loop.ts index 19d9f42..36ffebb 100644 --- a/packages/engine/src/loop.ts +++ b/packages/engine/src/loop.ts @@ -32,6 +32,8 @@ export class ResolutionLoop { private readonly host: LoopHost, private readonly onChange: () => void, private readonly intervalMs = RESOLUTION_INTERVAL_MS, + /** True while a pass runs, false when it ends (CR-021: "Catching up on your fleet…"). */ + private readonly onBusy?: (busy: boolean) => void, ) {} /** Resolves once now, then keeps going until `stop`. */ @@ -52,6 +54,7 @@ export class ResolutionLoop { /** One pass (ticks never overlap: a tick that is still running is reused). */ tick(): Promise { this.#running ??= (async () => { + this.onBusy?.(true); try { const result = await this.engine.resolveDue(this.#since); this.#since = result.now; @@ -60,6 +63,7 @@ export class ResolutionLoop { } } finally { this.#running = null; + this.onBusy?.(false); } })(); return this.#running; diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 89daa1f..128bd16 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -193,6 +193,8 @@ export const resolutionSummarySchema = z.object({ originId: idSchema, destinationId: idSchema, tutorial: z.boolean(), + /** Flown by an Independent pilot's own choice (CR-021): it goes in the pilot logbook. */ + independent: z.boolean(), /** An empty reposition flight: no cargo, no pay (CR-002). */ deadhead: z.boolean(), /** The fuel used, at the tank's average price (decision 0.5.98). */ diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index d3f3913..a770b7c 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -145,6 +145,7 @@ const summary = { tutorial: true, credits: 240, deadhead: false, + independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods",