diff --git a/DECISIONS.md b/DECISIONS.md index 863605a..75a4015 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/test/lighter-clipper.test.ts b/apps/server/test/lighter-clipper.test.ts new file mode 100644 index 0000000..089adde --- /dev/null +++ b/apps/server/test/lighter-clipper.test.ts @@ -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([]); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5dfa78c..9ac4f0b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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