Step 10A.7: switch the server to tutorial flow v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aea768bebf
commit
64fb944e8d
|
|
@ -525,3 +525,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`.
|
||||
- **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`.
|
||||
- **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`.
|
||||
|
||||
## Step 10A.7
|
||||
|
||||
- **The run counter increments when a tutorial dispatch resolves** (in the same locked resolution pass), not when it is dispatched, so "runs completed" means runs that landed.
|
||||
- **Purchase blocking happens first** in buy-ship, lease-bay and hire-pilot (`requireTutorialOver`), so a tutorial player gets `tutorial-active` before any other check and nothing changes.
|
||||
- **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
|
||||
- **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
|
||||
- **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ export interface BoardView {
|
|||
*/
|
||||
export async function getOrCreateBoard(
|
||||
tx: Queryable,
|
||||
key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number },
|
||||
key: {
|
||||
playerId: string;
|
||||
kind: "bay" | "tutorial";
|
||||
locationId: string;
|
||||
periodIndex: number;
|
||||
/** Tutorial boards: runs completed (CR-001). Bay boards: 0. */
|
||||
tutorialRun?: number;
|
||||
},
|
||||
generate: () => Contract[] | Promise<Contract[]>,
|
||||
now: RealTime,
|
||||
): Promise<Contract[]> {
|
||||
|
|
@ -40,13 +47,20 @@ export async function getOrCreateBoard(
|
|||
eq(contractBoards.kind, key.kind),
|
||||
eq(contractBoards.locationId, key.locationId),
|
||||
eq(contractBoards.periodIndex, key.periodIndex),
|
||||
eq(contractBoards.tutorialRun, key.tutorialRun ?? 0),
|
||||
);
|
||||
const [existing] = await tx.select().from(contractBoards).where(where);
|
||||
if (existing) return existing.contracts;
|
||||
|
||||
await tx
|
||||
.insert(contractBoards)
|
||||
.values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now })
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
...key,
|
||||
tutorialRun: key.tutorialRun ?? 0,
|
||||
contracts: await generate(),
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
const [stored] = await tx.select().from(contractBoards).where(where);
|
||||
return stored!.contracts;
|
||||
|
|
@ -74,7 +88,7 @@ export async function boardView(
|
|||
};
|
||||
}
|
||||
|
||||
/** The tutorial board for the current period at the loaner's location (Step 6.3). */
|
||||
/** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */
|
||||
export async function tutorialBoard(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
|
|
@ -86,13 +100,18 @@ export async function tutorialBoard(
|
|||
}
|
||||
const loanerLocationId = player.loanerLocationId as "earth" | "moon";
|
||||
const periodIndex = boardPeriodIndex(now, content.config);
|
||||
const runsCompleted = player.tutorialRunsCompleted;
|
||||
// One board per run (and per period), so every run gets three fresh contracts.
|
||||
const contracts = await getOrCreateBoard(
|
||||
tx,
|
||||
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
|
||||
() =>
|
||||
// Run counting arrives with the database change (Step 10A.7); until then
|
||||
// every board is the first run's.
|
||||
generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content),
|
||||
{
|
||||
playerId,
|
||||
kind: "tutorial",
|
||||
locationId: loanerLocationId,
|
||||
periodIndex,
|
||||
tutorialRun: runsCompleted,
|
||||
},
|
||||
() => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content),
|
||||
now,
|
||||
);
|
||||
return boardView(tx, playerId, contracts, periodIndex);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ export async function requirePlayer(tx: Queryable, playerId: string) {
|
|||
return player;
|
||||
}
|
||||
|
||||
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
|
||||
export async function requireTutorialOver(tx: Queryable, playerId: string, what: string) {
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
if (player.tutorialActive) {
|
||||
throw gameError("tutorial-active", `Finish the company runs before ${what}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deducts credits, or throws `insufficient-funds` without changing anything. */
|
||||
export async function spend(tx: Queryable, playerId: string, amount: number, what: string) {
|
||||
const company = await requireCompany(tx, playerId);
|
||||
|
|
@ -42,6 +50,7 @@ export async function leaseBay(
|
|||
locationId: string,
|
||||
now: RealTime,
|
||||
): Promise<Bay> {
|
||||
await requireTutorialOver(tx, playerId, "leasing a bay");
|
||||
const location = content.location(locationId);
|
||||
if (!location) throw gameError("not-found", `No location "${locationId}".`);
|
||||
|
||||
|
|
@ -82,6 +91,7 @@ export async function buyShip(
|
|||
modelId: string,
|
||||
now: RealTime,
|
||||
): Promise<Ship> {
|
||||
await requireTutorialOver(tx, playerId, "buying a ship");
|
||||
const model = content.shipModel(modelId);
|
||||
if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
|
||||
await spend(tx, playerId, model.price, `a ${model.name}`);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { and, count, eq } from "drizzle-orm";
|
|||
import type { Queryable } from "../db/client";
|
||||
import { bays, pilots, ships } from "../db/schema";
|
||||
import { gameError } from "../http/errors";
|
||||
import { requirePlayer, spend } from "./fleet";
|
||||
import { requirePlayer, requireTutorialOver, spend } from "./fleet";
|
||||
import { toApiPilot } from "./mappers";
|
||||
import { VALIDATION_MESSAGES } from "./messages";
|
||||
import { pilotName } from "./names";
|
||||
|
|
@ -44,6 +44,7 @@ export async function hirePilot(
|
|||
locationId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
await requireTutorialOver(tx, playerId, "hiring pilots");
|
||||
if (!(await hasBayAt(tx, playerId, locationId))) {
|
||||
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ export async function resolveArrivedDispatches(
|
|||
if (dispatch.tutorial) {
|
||||
await tx
|
||||
.update(players)
|
||||
.set({ loanerLocationId: resolution.shipLocationId })
|
||||
.set({
|
||||
loanerLocationId: resolution.shipLocationId,
|
||||
tutorialRunsCompleted: sql`${players.tutorialRunsCompleted} + 1`,
|
||||
})
|
||||
.where(eq(players.id, playerId));
|
||||
}
|
||||
if (resolution.discoversDestination) {
|
||||
|
|
|
|||
|
|
@ -3,19 +3,22 @@ import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shipName } from "../src/game/names";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp } from "./game";
|
||||
import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
const errorCode = (response: { json(): unknown }) =>
|
||||
errorResponseSchema.parse(response.json()).error.code;
|
||||
|
||||
describe("buy a ship", () => {
|
||||
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("courier")!.price;
|
||||
await setCredits(database().db, guest.playerId, price + 500);
|
||||
await setCredits(database().db, owner.playerId, price + 500);
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { ship } = shipResponseSchema.parse(response.json());
|
||||
expect(ship).toMatchObject({
|
||||
|
|
@ -27,40 +30,54 @@ describe("buy a ship", () => {
|
|||
});
|
||||
expect(ship.name.length).toBeGreaterThan(0);
|
||||
|
||||
const state = await getState(app, guest);
|
||||
const state = await getState(app, owner);
|
||||
expect(state.company.credits).toBe(500);
|
||||
expect(state.ships.map((s) => s.id)).toEqual([ship.id]);
|
||||
expect(state.ships.map((s) => s.id)).toEqual([owner.shipId, ship.id]);
|
||||
});
|
||||
|
||||
it("returns insufficient-funds and changes nothing without enough credits", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("freighter")!.price;
|
||||
await setCredits(database().db, guest.playerId, price - 1);
|
||||
await setCredits(database().db, owner.playerId, price - 1);
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "freighter" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "freighter" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
|
||||
expect(errorCode(response)).toBe("insufficient-funds");
|
||||
|
||||
const state = await getState(app, owner);
|
||||
expect(state.company.credits).toBe(price - 1);
|
||||
expect(state.ships).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns tutorial-active during the tutorial and changes nothing", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("tutorial-active");
|
||||
|
||||
const state = await getState(app, guest);
|
||||
expect(state.company.credits).toBe(price - 1);
|
||||
expect(state.company.credits).toBe(1_000_000);
|
||||
expect(state.ships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses to sell the company loaner", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "company-courier" });
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "company-courier" });
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown model", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "x-wing" });
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "x-wing" });
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found");
|
||||
expect(errorCode(response)).toBe("not-found");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeOwner, setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game";
|
||||
import { call, getState, putBoard, signUp, signUpOwner, testContract, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
|
@ -9,7 +9,7 @@ const testApp = useTestApp(database);
|
|||
describe("concurrency and money safety", () => {
|
||||
it("lets only one of two simultaneous purchases through when only one is affordable", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const guest = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("courier")!.price;
|
||||
await setCredits(database().db, guest.playerId, price + price / 2);
|
||||
|
||||
|
|
@ -20,13 +20,14 @@ describe("concurrency and money safety", () => {
|
|||
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||
|
||||
const state = await getState(app, guest);
|
||||
expect(state.ships).toHaveLength(1);
|
||||
// The owner already had one ship.
|
||||
expect(state.ships).toHaveLength(2);
|
||||
expect(state.company.credits).toBe(price / 2);
|
||||
});
|
||||
|
||||
it("never goes negative under many simultaneous purchases", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const guest = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("courier")!.price;
|
||||
await setCredits(database().db, guest.playerId, price * 3);
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ describe("concurrency and money safety", () => {
|
|||
);
|
||||
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
|
||||
const state = await getState(app, guest);
|
||||
expect(state.ships).toHaveLength(3);
|
||||
expect(state.ships).toHaveLength(1 + 3);
|
||||
expect(state.company.credits).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ describe("concurrency and money safety", () => {
|
|||
|
||||
it("leases one bay when the same lease is sent twice at once", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const guest = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, guest.playerId, 100_000);
|
||||
const responses = await Promise.all([
|
||||
call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
|
||||
|
|
@ -71,7 +72,7 @@ describe("concurrency and money safety", () => {
|
|||
]);
|
||||
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||
const state = await getState(app, guest);
|
||||
expect(state.bays).toHaveLength(1);
|
||||
expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
|
||||
expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import {
|
||||
contractBoardResponseSchema,
|
||||
dispatchResponseSchema,
|
||||
errorResponseSchema,
|
||||
} from "@deadhead/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pilots, players } from "../src/db/schema";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp, type Guest } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
async function buyShipAndEarthBay(guest: Guest) {
|
||||
const { app } = testApp();
|
||||
expect((await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await call(app, guest, "POST", "/api/bays", { locationId: "earth" })).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
describe("end of tutorial", () => {
|
||||
it("ends when the player owns a ship and an Earth bay, moving the avatar to Earth unassigned", async () => {
|
||||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(db, guest.playerId, 1_000_000);
|
||||
// Put the loaner and avatar at the Moon.
|
||||
await db
|
||||
.update(players)
|
||||
.set({ loanerLocationId: "moon" })
|
||||
.where(eq(players.id, guest.playerId));
|
||||
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, guest.avatarId));
|
||||
|
||||
await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
|
||||
expect((await getState(app, guest)).tutorial).not.toBeNull();
|
||||
await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
|
||||
|
||||
const state = await getState(app, guest);
|
||||
expect(state.tutorial).toBeNull();
|
||||
expect(state.pilots[0]).toMatchObject({
|
||||
isAvatar: true,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
assignedShipId: null,
|
||||
});
|
||||
|
||||
const board = await call(app, guest, "GET", "/api/tutorial/board");
|
||||
expect(board.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(board.json()).error.code).toBe("not-in-tutorial");
|
||||
});
|
||||
|
||||
it("waits for an in-flight tutorial run to land before ending", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
const [contract] = contractBoardResponseSchema.parse(
|
||||
(await call(app, guest, "GET", "/api/tutorial/board")).json(),
|
||||
).contracts;
|
||||
const { dispatch } = dispatchResponseSchema.parse(
|
||||
(
|
||||
await call(app, guest, "POST", "/api/dispatches", {
|
||||
kind: "tutorial",
|
||||
contractId: contract!.id,
|
||||
})
|
||||
).json(),
|
||||
);
|
||||
|
||||
await buyShipAndEarthBay(guest);
|
||||
expect((await getState(app, guest)).tutorial).toMatchObject({
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: true,
|
||||
});
|
||||
|
||||
clock.now = dispatch.arrivalAt;
|
||||
const state = await getState(app, guest);
|
||||
expect(state.tutorial).toBeNull();
|
||||
// The run still paid out, and the avatar was brought back to Earth.
|
||||
expect(state.company.credits).toBe(
|
||||
1_000_000 -
|
||||
content.shipModel("courier")!.price -
|
||||
content.location("earth")!.bayLeasePrice +
|
||||
contract!.payout,
|
||||
);
|
||||
expect(state.pilots[0]).toMatchObject({
|
||||
locationId: "earth",
|
||||
status: "idle",
|
||||
assignedShipId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not end with only a ship, or only an Earth bay", async () => {
|
||||
const { app } = testApp();
|
||||
const shipOnly = await signUp(app);
|
||||
await setCredits(database().db, shipOnly.playerId, 1_000_000);
|
||||
await call(app, shipOnly, "POST", "/api/ships", { modelId: "courier" });
|
||||
expect((await getState(app, shipOnly)).tutorial).not.toBeNull();
|
||||
|
||||
const bayOnly = await signUp(app);
|
||||
await setCredits(database().db, bayOnly.playerId, 1_000_000);
|
||||
await call(app, bayOnly, "POST", "/api/bays", { locationId: "earth" });
|
||||
expect((await getState(app, bayOnly)).tutorial).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ import { buildApp } from "../src/app";
|
|||
import { createRecordingQueue } from "../src/jobs/queue";
|
||||
import type { Database, Db } from "../src/db/client";
|
||||
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
|
||||
import { TEST_NOW } from "./db";
|
||||
import { makeOwner, TEST_NOW } from "./db";
|
||||
import { testConfig } from "./helpers";
|
||||
|
||||
/** A clock tests can move. Starts at `TEST_NOW`. */
|
||||
|
|
@ -198,3 +198,13 @@ export async function putBoard(db: Db, playerId: string, contracts: Contract[],
|
|||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
/** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */
|
||||
export async function signUpOwner(
|
||||
app: TestApp["app"],
|
||||
db: Db,
|
||||
): Promise<Guest & { shipId: string }> {
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
return { ...guest, shipId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
|
|||
import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp } from "./game";
|
||||
import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
|
@ -11,53 +11,64 @@ const errorCode = (response: { json(): unknown }) =>
|
|||
errorResponseSchema.parse(response.json()).error.code;
|
||||
|
||||
describe("lease a bay", () => {
|
||||
it("leases a bay at Earth and deducts the price", async () => {
|
||||
it("leases a bay at the Moon and deducts the price", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const price = content.location("earth")!.bayLeasePrice;
|
||||
await setCredits(database().db, guest.playerId, price + 100);
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const price = content.location("moon")!.bayLeasePrice;
|
||||
await setCredits(database().db, owner.playerId, price + 100);
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
|
||||
const response = await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { bay } = bayResponseSchema.parse(response.json());
|
||||
expect(bay.locationId).toBe("earth");
|
||||
expect(bay.locationId).toBe("moon");
|
||||
|
||||
const state = await getState(app, guest);
|
||||
const state = await getState(app, owner);
|
||||
expect(state.company.credits).toBe(100);
|
||||
expect(state.bays).toEqual([bay]);
|
||||
expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
|
||||
});
|
||||
|
||||
it("refuses an undiscovered location", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
const response = await call(app, guest, "POST", "/api/bays", { locationId: "mars" });
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
const response = await call(app, owner, "POST", "/api/bays", { locationId: "mars" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("location-undiscovered");
|
||||
expect((await getState(app, guest)).company.credits).toBe(1_000_000);
|
||||
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("refuses a second bay at the same location", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
await call(app, guest, "POST", "/api/bays", { locationId: "moon" });
|
||||
const response = await call(app, guest, "POST", "/api/bays", { locationId: "moon" });
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
const response = await call(app, owner, "POST", "/api/bays", { locationId: "earth" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("bay-already-leased");
|
||||
const state = await getState(app, guest);
|
||||
const state = await getState(app, owner);
|
||||
expect(state.bays).toHaveLength(1);
|
||||
expect(state.company.credits).toBe(1_000_000 - content.location("moon")!.bayLeasePrice);
|
||||
expect(state.company.credits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("refuses when the player cannot afford it, and unknown locations", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 0);
|
||||
expect(errorCode(await call(app, guest, "POST", "/api/bays", { locationId: "earth" }))).toBe(
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 0);
|
||||
expect(errorCode(await call(app, owner, "POST", "/api/bays", { locationId: "moon" }))).toBe(
|
||||
"insufficient-funds",
|
||||
);
|
||||
const unknown = await call(app, guest, "POST", "/api/bays", { locationId: "pluto" });
|
||||
const unknown = await call(app, owner, "POST", "/api/bays", { locationId: "pluto" });
|
||||
expect(unknown.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns tutorial-active during the tutorial and changes nothing", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("tutorial-active");
|
||||
const state = await getState(app, guest);
|
||||
expect(state.bays).toEqual([]);
|
||||
expect(state.company.credits).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,12 +134,18 @@ describe("assigning pilots", () => {
|
|||
it("refuses to assign the avatar during the tutorial", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 100_000);
|
||||
const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as {
|
||||
ship: { id: string };
|
||||
};
|
||||
// Ships cannot be bought during the tutorial, so put one at Earth directly.
|
||||
const shipId = randomUUID();
|
||||
await database().db.insert(ships).values({
|
||||
id: shipId,
|
||||
playerId: guest.playerId,
|
||||
modelId: "courier",
|
||||
name: "Gift Courier",
|
||||
locationId: "earth",
|
||||
createdAt: TEST_NOW,
|
||||
});
|
||||
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, {
|
||||
shipId: ship.ship.id,
|
||||
shipId,
|
||||
});
|
||||
expect(errorCode(response)).toBe("avatar-in-tutorial");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
contractBoardResponseSchema,
|
||||
dispatchResponseSchema,
|
||||
type PlayerState,
|
||||
} from "@deadhead/schema";
|
||||
import { call, getState, type Guest, type TestApp } from "./game";
|
||||
|
||||
/** The tutorial board the player sees now. */
|
||||
export async function tutorialBoard(app: TestApp["app"], guest: Guest) {
|
||||
const response = await call(app, guest, "GET", "/api/tutorial/board");
|
||||
if (response.statusCode !== 200) throw new Error(`board failed: ${response.body}`);
|
||||
return contractBoardResponseSchema.parse(response.json()).contracts;
|
||||
}
|
||||
|
||||
/** Flies one tutorial run (the given slot, light by default) and lets it land. */
|
||||
export async function flyTutorialRun(
|
||||
testApp: TestApp,
|
||||
guest: Guest,
|
||||
slot = 0,
|
||||
): Promise<PlayerState> {
|
||||
const { app, clock } = testApp;
|
||||
const contract = (await tutorialBoard(app, guest))[slot]!;
|
||||
const response = await call(app, guest, "POST", "/api/dispatches", {
|
||||
kind: "tutorial",
|
||||
contractId: contract.id,
|
||||
});
|
||||
if (response.statusCode !== 200) throw new Error(`dispatch failed: ${response.body}`);
|
||||
const { dispatch } = dispatchResponseSchema.parse(response.json());
|
||||
clock.now = dispatch.arrivalAt;
|
||||
return getState(app, guest);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useTestDatabase } from "./db";
|
||||
import { getState, signUp, useTestApp } from "./game";
|
||||
import { flyTutorialRun } from "./tutorial-flow";
|
||||
|
||||
// The deal after three runs instead of four: the loaner is then at the Moon.
|
||||
vi.mock("@deadhead/content", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@deadhead/content")>();
|
||||
return {
|
||||
...original,
|
||||
content: {
|
||||
...original.content,
|
||||
config: { ...original.content.config, tutorialRunsBeforeDeal: 3 },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
describe("deal availability with tutorialRunsBeforeDeal = 3", () => {
|
||||
it("waits until a fourth run returns the loaner to Earth", async () => {
|
||||
const guest = await signUp(testApp().app);
|
||||
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
|
||||
expect((await getState(testApp().app, guest)).tutorial).toMatchObject({
|
||||
runsCompleted: 3,
|
||||
loanerLocationId: "moon",
|
||||
dealAvailable: false,
|
||||
});
|
||||
|
||||
const state = await flyTutorialRun(testApp(), guest);
|
||||
expect(state.tutorial).toMatchObject({
|
||||
runsCompleted: 4,
|
||||
loanerLocationId: "earth",
|
||||
dealAvailable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { errorResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp } from "./game";
|
||||
import { flyTutorialRun, tutorialBoard } from "./tutorial-flow";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
describe("tutorial flow v2", () => {
|
||||
it("starts a guest with the starting cushion, the Company Courier loaner at Earth and no runs", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const state = await getState(app, guest);
|
||||
expect(state.company).toMatchObject({
|
||||
credits: content.config.startingCredits,
|
||||
loanBalance: 0,
|
||||
});
|
||||
expect(state.tutorial).toEqual({
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: false,
|
||||
runsCompleted: 0,
|
||||
dealAvailable: false,
|
||||
});
|
||||
expect(state.pilots[0]).toMatchObject({ isAvatar: true, locationId: "earth" });
|
||||
expect(state.goals).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts each resolved run and gives every run a fresh three-contract board", async () => {
|
||||
const guest = await signUp(testApp().app);
|
||||
const first = await tutorialBoard(testApp().app, guest);
|
||||
expect(first).toHaveLength(3);
|
||||
|
||||
const state = await flyTutorialRun(testApp(), guest);
|
||||
expect(state.tutorial).toMatchObject({ runsCompleted: 1, loanerLocationId: "moon" });
|
||||
|
||||
const second = await tutorialBoard(testApp().app, guest);
|
||||
expect(second).toHaveLength(3);
|
||||
for (const c of second) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
||||
expect(second.map((c) => c.id)).not.toEqual(first.map((c) => c.id));
|
||||
});
|
||||
|
||||
it("offers Unmarked crates on the third run's board", async () => {
|
||||
const guest = await signUp(testApp().app);
|
||||
await flyTutorialRun(testApp(), guest);
|
||||
await flyTutorialRun(testApp(), guest);
|
||||
const third = await tutorialBoard(testApp().app, guest);
|
||||
expect(third.find((c) => c.scriptedId === "unmarked-crates")).toBeDefined();
|
||||
});
|
||||
|
||||
it("makes the deal available after four runs ending at Earth, not after three", async () => {
|
||||
const guest = await signUp(testApp().app);
|
||||
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
|
||||
const afterThree = await getState(testApp().app, guest);
|
||||
expect(afterThree.tutorial).toMatchObject({
|
||||
runsCompleted: 3,
|
||||
loanerLocationId: "moon",
|
||||
dealAvailable: false,
|
||||
});
|
||||
|
||||
const afterFour = await flyTutorialRun(testApp(), guest);
|
||||
expect(afterFour.tutorial).toMatchObject({
|
||||
runsCompleted: 4,
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: false,
|
||||
dealAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks hiring pilots during the tutorial", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
const response = await call(app, guest, "POST", "/api/pilots", { locationId: "earth" });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-active");
|
||||
expect((await getState(app, guest)).pilots).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
|||
- [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9.
|
||||
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
|
||||
- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated.
|
||||
- [ ] Step 10A.7 — Server: tutorial flow v2
|
||||
- [x] **Step 10A.7 — Server: tutorial flow v2.** Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments `tutorial_runs_completed`; `dealAvailable` in state; buying ships, leasing bays and hiring pilots return `409 tutorial-active` during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8).
|
||||
- [ ] Step 10A.8 — Server: accept the deal
|
||||
- [ ] Step 10A.9 — Server: loan repayment
|
||||
- [ ] Step 10A.10 — Server: goals
|
||||
|
|
|
|||
Loading…
Reference in New Issue