Step 10L.6: would-strand on the server, buying the new ships

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:43:03 -04:00
parent 8d8df71ba9
commit 6bd358b52d
3 changed files with 86 additions and 1 deletions

View File

@ -1164,3 +1164,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The Freighter's 15 % tanks override stays**: 450 → 652.5M km maxed, short of the Bulk Hauler (1,250) and of Jupiter from Earth at its farthest (965). Its comment now quotes the new numbers.
- **No caps for the new models**: with the default percentages a maxed Clipper holds 43.5 t (Freighter 100), a maxed Courier flies 3.9M km/h (Clipper 6.5M), and a maxed Lighter's range is 16M km (20.8 from the Moon), far below Mars's closest 58.5.
- **The Lighter cap is a sim test** because it needs distances; the plain stat inequalities stay in the content test.
## Step 10L.6
- **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids).
- **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently.

View File

@ -0,0 +1,80 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { discoveredLocations, pilots, ships } from "../src/db/schema";
import { setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("Lighter and Clipper (Step 10L.6)", () => {
for (const modelId of ["lighter", "clipper"]) {
it(`buys a ${modelId} at the chosen HQ`, async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.shipModel(modelId)!.price;
await setCredits(database().db, owner.playerId, price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId,
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ modelId, status: "idle", locationId: "earth" });
expect((await getState(app, owner)).company.credits).toBe(0);
});
}
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const db = database().db;
await setCredits(db, owner.playerId, 100_000);
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
clock.now = gameToReal(departure, content.config);
await db
.update(ships)
.set({ modelId: "clipper", locationId: "moon" })
.where(eq(ships.id, owner.shipId));
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, owner.avatarId));
await db
.insert(discoveredLocations)
.values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW });
// The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
expect(
wouldStrand(
clipper,
moon,
mars,
0,
realToGame(clock.now, content.config),
undefined,
content,
),
).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");
const after = await getState(app, owner);
expect(after.company.credits).toBe(before.company.credits);
expect(after.ships).toEqual(before.ships);
expect(after.activeDispatches).toEqual([]);
});
});

View File

@ -274,7 +274,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
- [x] **Step 10L.3 — Sim: no-strand rule.** `wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content)`: at arrival plus `noStrandMarginGameHours` (48), can the ship reach any other discovered location from the destination with its range from there? `validateDispatch` (new optional `discoveredIds`; absent = all) and `validateReposition` fail with `would-strand` after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (`no-strand.test.ts`): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin.
- [x] **Step 10L.4 — Content tuning: reach table.** Ranges: Courier 5, Lighter 10, Clipper 198 (unchanged from 10L.1), **Freighter 650 → 450**, **Bulk Hauler 1000 → 1250**. Step 3.7's range tests (`eligibility.test.ts`) now name their origin; the Freighter-to-Ceres-at-farthest and Freighter-to-Jupiter-at-closest tests become "reaches Ceres at its closest, not its farthest" and "never reaches Jupiter from Earth". Tests (`reach-table.test.ts`): one per §3 cell over monthly samples for 16 game years, plus the two hop tests.
- [x] **Step 10L.5 — Upgrade caps for the new lineup.** Courier fuel-tanks override 600 → **450 %** per level (5 → 27.5 → 50 → 72.5M km); the Freighter override stays at 15 % (450 → 652.5). No Lighter or Clipper caps were needed. Tests: the new 0.5.67 inequalities (content `upgrades.test.ts`; sim `upgrade-caps.test.ts`: a maxed Lighter never reaches Mars from Earth or the Moon, only a Bulk Hauler reaches Jupiter from Earth at its farthest even with every ship maxed); `courier-mars.test.ts` now samples 16 years from Earth. The web deadhead-to-Mars test moved to month 176, Mars's closest approach.
- [ ] Step 10L.6 — Schema and server
- [x] **Step 10L.6 — Schema and server.** `would-strand` is in `ERROR_CODES` (it comes from the sim's failure lists) with a server message; buy-ship already accepts any purchasable model. Tests (`apps/server/test/lighter-clipper.test.ts`): buying a Lighter and a Clipper places them idle at the chosen HQ; a Clipper at the Moon deadheading to Mars on a stranding date gets 409 `would-strand` and credits, ships and flights are unchanged.
- [ ] Step 10L.7 — Client: Market shows roles and reach
- [ ] Step 10L.8 — Client: reachability from where ships actually are
- [ ] Step 10L.9 — Client: stranding reason