Step 6.7: end the tutorial automatically
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a5a22a43d
commit
badbac1890
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue