Fix: boards ignore Independent pilots (decision 0.5.133)
CI / check (push) Successful in 7m16s Details

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 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 16:58:54 -04:00
parent 90bc8e814e
commit eb9b0de5e4
6 changed files with 84 additions and 3 deletions

View File

@ -1766,3 +1766,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 22.4 ## 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. - **"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.

View File

@ -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 | | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes |
| `buyDispatchOffice` / `setSpendingFloor` | `POST /api/company/dispatch-office`, `/api/company/spending-floor` | `{}` / `credits` | `{ company, dispatchOffice }` (CR-021) | 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 | | `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 | | `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 | | `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 | | `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes |

View File

@ -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. 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. 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. 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) ### 0.6 Translating change requests written for the server (CR-014 §7)

View File

@ -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. - [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. **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.

View File

@ -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 * 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 * 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. * 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( export function boardFleet(
save: SaveDocument, save: SaveDocument,
locationId: string, locationId: string,
periodEndsAt: RealTime, periodEndsAt: RealTime,
): FleetMember[] { ): 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)) .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.map((ship) => { .map((ship) => {
const flight = const flight =
@ -126,7 +149,7 @@ export function boardFleet(
return qualifyingFleet({ return qualifyingFleet({
locationId, locationId,
ships, ships,
pilots: save.pilots.map((p) => ({ pilots: pilots.map((p) => ({
id: p.id, id: p.id,
skills: p.skills, skills: p.skills,
assignedShipId: p.assignedShipId, assignedShipId: p.assignedShipId,

View File

@ -1,6 +1,7 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { boardPeriodMs } from "@deadhead/sim"; import { boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { boardFleet } from "../src/game/boards";
import { encodeSave } from "../src/index"; import { encodeSave } from "../src/index";
import { import {
addPilot, 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); 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"]);
});
});