From eb9b0de5e4fb6b2f113e7f63714b4f0311e000af Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 11 Oct 2026 16:58:54 -0400 Subject: [PATCH] Fix: boards ignore Independent pilots (decision 0.5.133) The board's qualifying fleet counted Independent pilots and their ships, so the player saw jobs only an Independent pilot could fly. Generation and the read filter now use the player's Manual pairs, falling back to the whole fleet when there are none. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ memory-bank/architecture.md | 2 +- memory-bank/implementation-plan.md | 1 + memory-bank/progress.md | 4 ++ packages/engine/src/game/boards.ts | 27 ++++++++++- .../engine/test/completable-boards.test.ts | 47 +++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 5f4faf7..241aed1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1766,3 +1766,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 22.4 - **"First dispatch under 30 seconds" is a new end-to-end check**, since this repo's Phase 12 had no such target. The balance simulator has no screens, so a welcome can't slow it. The new e2e test times a real start, from "Start your company" through Skip to the first tutorial run in flight, against 30 seconds. + +## Fix: boards ignore Independent pilots + +- **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. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 7bb8b0c..cbecefa 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -51,7 +51,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | | `buyDispatchOffice` / `setSpendingFloor` | `POST /api/company/dispatch-office`, `/api/company/spending-floor` | `{}` / `credits` | `{ company, dispatchOffice }` (CR-021) | yes | | `setPilotMode` / `setPilotOrders` | `POST /api/pilots/:pilotId/mode`, `/orders` | `pilotId`, `mode` / `orders` | `{ pilot }` (CR-021) | yes | -| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` (only jobs the qualifying fleet can complete, CR-019) | yes | +| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` (only jobs the qualifying fleet can complete, CR-019; Independent pilots and their ships don't count, decision 0.5.133) | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | | `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes | diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index deda4bd..d742547 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -188,6 +188,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 130. **No independent-trip notifications** (CR-021). Independent trips are decided on the device as time is replayed, so none are scheduled as notifications. 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. ### 0.6 Translating change requests written for the server (CR-014 §7) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 377b1a9..3c21d71 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -500,3 +500,7 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0 - [x] **Step 22.4 — Regression.** New e2e `apps/web/e2e/22-4-first-dispatch.e2e.ts`: from "Start your company", through Skip on the welcome, to the first tutorial run in flight takes under 30 seconds. Full suite: typecheck, lint, 1414 unit tests and 10 e2e tests pass. **Phase 22 is complete; stopped for review.** Manual check for the user: start a new company on a phone and read through the three cards (Next, swipe, Skip), then check Settings → Help → How to play. + +## 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. diff --git a/packages/engine/src/game/boards.ts b/packages/engine/src/game/boards.ts index a4c12c5..5367d03 100644 --- a/packages/engine/src/game/boards.ts +++ b/packages/engine/src/game/boards.ts @@ -100,13 +100,36 @@ export function tutorialBoard(save: SaveDocument, random: RandomSource, now: Rea * The qualifying fleet for a location's board as the save stands (decision * 0.5.115): ships in a stable order so the same fleet always gives the same * board, with upgrades (CR-009), fuel, and where flying ships are headed. + * Only the pairs the player sends count (decision 0.5.133): Independent pilots + * and their ships are left out, since they check each job against their own + * pair; with nothing left, the whole fleet counts, so they still get a board. */ export function boardFleet( save: SaveDocument, locationId: string, periodEndsAt: RealTime, ): FleetMember[] { - const ships = [...save.ships] + const independent = save.pilots.filter((p) => p.mode === "independent"); + const theirShips = new Set(independent.map((p) => p.assignedShipId)); + const manual = fleetFor( + save, + locationId, + periodEndsAt, + save.ships.filter((s) => !theirShips.has(s.id)), + save.pilots.filter((p) => p.mode !== "independent"), + ); + if (manual.length > 0 || independent.length === 0) return manual; + return fleetFor(save, locationId, periodEndsAt, save.ships, save.pilots); +} + +function fleetFor( + save: SaveDocument, + locationId: string, + periodEndsAt: RealTime, + ownShips: SaveDocument["ships"], + pilots: SaveDocument["pilots"], +): FleetMember[] { + const ships = [...ownShips] .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .map((ship) => { const flight = @@ -126,7 +149,7 @@ export function boardFleet( return qualifyingFleet({ locationId, ships, - pilots: save.pilots.map((p) => ({ + pilots: pilots.map((p) => ({ id: p.id, skills: p.skills, assignedShipId: p.assignedShipId, diff --git a/packages/engine/test/completable-boards.test.ts b/packages/engine/test/completable-boards.test.ts index 9ea472e..af0231e 100644 --- a/packages/engine/test/completable-boards.test.ts +++ b/packages/engine/test/completable-boards.test.ts @@ -1,6 +1,7 @@ import { content } from "@deadhead/content"; import { boardPeriodMs } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; +import { boardFleet } from "../src/game/boards"; import { encodeSave } from "../src/index"; import { addPilot, @@ -101,3 +102,49 @@ describe("completable boards in the engine (Step 19.3, decisions 0.5.114–0.5.1 expect((await readSave(device)).contractBoards).toHaveLength(1); }); }); + +describe("boards ignore Independent pilots (decision 0.5.133)", () => { + async function withIndependentFreighter() { + const { app, clock } = testApp(); + await signUpOwner(app); + await addShip(app, { id: "f1", modelId: "freighter", name: "Big Wren" }); + await addPilot(app, { id: "p1", name: "Rosa", assignedShipId: "f1" }); + await setCredits(app, content.config.independentPilots.dispatchOfficePrice); + await app.call("buyDispatchOffice", {}); + return { app, clock }; + } + + it("shows only jobs a Manual pilot can fly once Rosa goes Independent", async () => { + const { app, clock } = await withIndependentFreighter(); + const before = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + expect(before.some((c) => c.massTonnes > COURIER_HOLD)).toBe(true); + + await app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + // This period: the Freighter's jobs are hidden at once. + const now = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + expect(now.filter((c) => c.massTonnes > COURIER_HOLD)).toEqual([]); + // Later periods are generated for the player's Courier alone. + for (let period = 0; period < 5; period++) { + clock.now += PERIOD; + const board = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + expect(board.length).toBeGreaterThan(0); + expect(board.filter((c) => c.massTonnes > COURIER_HOLD)).toEqual([]); + } + }); + + it("falls back to the whole fleet when every pair is Independent", async () => { + const { app } = await withIndependentFreighter(); + await app.call("setPilotMode", { pilotId: "p1", mode: "independent" }); + const save = await readSave(app); + // No Manual pair left: only Rosa and her Freighter. + const alone = { + ...save, + ships: save.ships.filter((s) => s.id === "f1"), + pilots: save.pilots.filter((p) => p.id === "p1"), + }; + expect(boardFleet(alone, "earth", TEST_NOW + PERIOD).map((m) => m.modelId)).toEqual([ + "freighter", + ]); + expect(boardFleet(save, "earth", TEST_NOW + PERIOD).map((m) => m.modelId)).toEqual(["courier"]); + }); +});