Step 6.7: end the tutorial automatically

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:11:24 -04:00
parent 6a5a22a43d
commit badbac1890
6 changed files with 174 additions and 4 deletions

View File

@ -293,3 +293,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **A bay lease is a one-time payment** of the location's `bayLeasePrice`, as the plan words it ("one-time lease price"); there is no recurring rent in the base game.
- **Check order:** location exists (`404 not-found`), discovered (`409 location-undiscovered`), no bay there yet (`409 bay-already-leased`), affordable (`409 insufficient-funds`).
## Step 6.7
- **The end-of-tutorial check runs in two places:** at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in `withPlayer`. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger.
- **"Removing the loaner"** means clearing `players.loaner_location_id` and `tutorial_active`; the loaner never had a ship row.
- **The avatar is moved to Earth** as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4).

View File

@ -3,13 +3,15 @@ import { resolveDispatch, type RealTime } from "@deadhead/sim";
import { and, asc, eq, lte, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema";
import { maybeEndTutorial } from "./tutorial";
/**
* Resolves every in-flight dispatch of a player that has arrived by `now`, in
* arrival order, applying credits, XP, ship/pilot location and discovery. Run
* it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
* exactly once: the status update is conditional, and effects are applied only
* when it changed a row. Returns how many dispatches were resolved.
* when it changed a row. Ends the tutorial if its last run just landed
* (Step 6.7). Returns how many dispatches were resolved.
*/
export async function resolveArrivedDispatches(
tx: Queryable,
@ -90,5 +92,6 @@ export async function resolveArrivedDispatches(
discoveredIds.add(dispatch.record.destinationId);
}
}
if (resolvedCount > 0) await maybeEndTutorial(tx, playerId);
return resolvedCount;
}

View File

@ -0,0 +1,49 @@
import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, dispatches, pilots, players, ships } from "../db/schema";
/** Where the avatar ends up when the tutorial ends (decision 0.5.4). */
export const TUTORIAL_END_LOCATION_ID = "earth";
/**
* Ends the tutorial once the player owns a ship, has an Earth bay, and has no
* tutorial run in flight (decision 0.5.4): the loaner is removed and the avatar
* becomes a normal, unassigned pilot at Earth. Returns whether it ended now.
*/
export async function maybeEndTutorial(tx: Queryable, playerId: string): Promise<boolean> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive) return false;
const [ship] = await tx
.select({ id: ships.id })
.from(ships)
.where(eq(ships.playerId, playerId))
.limit(1);
if (!ship) return false;
const [earthBay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, TUTORIAL_END_LOCATION_ID)));
if (!earthBay) return false;
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) return false;
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null })
.where(eq(players.id, playerId));
await tx
.update(pilots)
.set({ locationId: TUTORIAL_END_LOCATION_ID, assignedShipId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
return true;
}

View File

@ -1,11 +1,13 @@
import type { Queryable } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import { maybeEndTutorial } from "../game/tutorial";
import type { AppContext } from "./context";
/**
* Runs a game action for a player: takes the player lock, applies any arrivals
* that are due (so the action sees current state), then runs `fn`.
* that are due (so the action sees current state), runs `fn`, then ends the
* tutorial if the action completed its conditions (Step 6.7).
*/
export function withPlayer<T>(
ctx: AppContext,
@ -15,6 +17,8 @@ export function withPlayer<T>(
const now = ctx.clock();
return withPlayerLock(ctx.db, playerId, async (tx) => {
await resolveArrivedDispatches(tx, playerId, now);
return fn(tx, now);
const result = await fn(tx, now);
await maybeEndTutorial(tx, playerId);
return result;
});
}

View File

@ -0,0 +1,108 @@
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).toEqual({
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();
});
});

View File

@ -60,7 +60,7 @@
- [x] **Step 6.4 — Tutorial dispatch.** `POST /api/dispatches` with `kind: "tutorial"` (`src/game/dispatch.ts`): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar.
- [x] **Step 6.5 — Buy a ship.** `POST /api/ships` (`src/game/fleet.ts`, `src/routes/fleet.ts`): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (`src/game/names.ts`); `409 insufficient-funds` changes nothing.
- [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`.
- [ ] Step 6.7 — End tutorial
- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
- [ ] Step 6.8 — Hire and assign pilots
- [ ] Step 6.9 — Bay contract board endpoint
- [ ] Step 6.10 — Contract dispatch