diff --git a/DECISIONS.md b/DECISIONS.md index 95f8fd0..11bfb24 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -540,3 +540,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The endpoint returns the full new `PlayerState`** so the client can switch straight to the post-tutorial UI. - **Exactly once:** the player lock serializes accepts, and `isDealAvailable` requires `deal_accepted` to be false, so a second or simultaneous accept fails with `deal-not-available` and changes nothing. - **The old automatic tutorial end (`maybeEndTutorial`, Step 6.7) is deleted;** accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced). + +## Step 10A.9 + +- **Within one resolution pass the balance carries forward:** each arrival repays against what the previous one left, so several arrivals resolving together never repay more than the balance. +- **Repaying is checked against the amount actually taken:** `payment = min(amount, balance)`; if that exceeds credits the request fails with `insufficient-funds` and nothing changes. With a zero balance, a repay request succeeds and moves nothing. +- **The repay endpoint returns `{ company }`** (credits and balance) and runs inside `withPlayer` like every money change. diff --git a/apps/server/src/game/loan.ts b/apps/server/src/game/loan.ts new file mode 100644 index 0000000..0df2c44 --- /dev/null +++ b/apps/server/src/game/loan.ts @@ -0,0 +1,33 @@ +import type { Company } from "@deadhead/schema"; +import { eq, sql } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { companies } from "../db/schema"; +import { gameError } from "../http/errors"; +import { requireCompany } from "./fleet"; + +/** + * Pays the loan down by up to `amount` credits (decision 0.5.32): only the + * balance is taken, and paying more than the player has fails with no change. + * Run inside the player lock. + */ +export async function repayLoan(tx: Queryable, playerId: string, amount: number): Promise { + const company = await requireCompany(tx, playerId); + const payment = Math.min(amount, company.loanBalance); + if (payment > company.credits) { + throw gameError("insufficient-funds", `You only have ${company.credits} credits.`); + } + const [updated] = await tx + .update(companies) + .set({ + credits: sql`${companies.credits} - ${payment}`, + loanBalance: sql`${companies.loanBalance} - ${payment}`, + }) + .where(eq(companies.playerId, playerId)) + .returning(); + return { + id: updated!.id, + name: updated!.name, + credits: updated!.credits, + loanBalance: updated!.loanBalance, + }; +} diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 12dab66..8740124 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -6,7 +6,8 @@ import { companies, discoveredLocations, dispatches, pilots, players, ships } fr /** * 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 + * arrival order, applying credits (net of loan repayment), the repayment, 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. @@ -34,6 +35,9 @@ export async function resolveArrivedDispatches( .from(discoveredLocations) .where(eq(discoveredLocations.playerId, playerId)); const discoveredIds = new Set(discovered.map((d) => d.locationId)); + const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); + // Carried forward so each arrival repays against the balance left by the previous one. + let loanBalance = company?.loanBalance ?? 0; let resolvedCount = 0; for (const dispatch of due) { @@ -41,14 +45,7 @@ export async function resolveArrivedDispatches( if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); const result = resolveDispatch( - // The loan balance is applied in Step 10A.9. - { - record: dispatch.record, - pilotIsAvatar: pilot.isAvatar, - discoveredIds, - loanBalance: 0, - now, - }, + { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now }, content, ); if (result.status !== "resolved") continue; @@ -61,10 +58,14 @@ export async function resolveArrivedDispatches( .returning({ id: dispatches.id }); if (claimed.length === 0) continue; resolvedCount++; + loanBalance -= resolution.loanRepayment; await tx .update(companies) - .set({ credits: sql`${companies.credits} + ${resolution.credits}` }) + .set({ + credits: sql`${companies.credits} + ${resolution.credits}`, + loanBalance: sql`${companies.loanBalance} - ${resolution.loanRepayment}`, + }) .where(eq(companies.playerId, playerId)); await tx diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 733b118..83b18eb 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -5,6 +5,7 @@ import { realToGame } from "@deadhead/sim"; import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance } from "fastify"; import { acceptDeal } from "../game/deal"; +import { repayLoan } from "../game/loan"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; import { acknowledgeSummaries } from "../game/summaries"; @@ -57,4 +58,10 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi return loadPlayerState(tx, playerId); }), ); + + registerRoute(app, routes.repayLoan, options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx) => ({ + company: await repayLoan(tx, playerId, input.amount), + })), + ); } diff --git a/apps/server/test/loan.test.ts b/apps/server/test/loan.test.ts new file mode 100644 index 0000000..ca789da --- /dev/null +++ b/apps/server/test/loan.test.ts @@ -0,0 +1,131 @@ +import { content } from "@deadhead/content"; +import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { companies } from "../src/db/schema"; +import { setCredits, useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + signUpOwner, + testContract, + useTestApp, + type Guest, +} from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +async function setLoan(guest: Guest, loanBalance: number) { + await database() + .db.update(companies) + .set({ loanBalance }) + .where(eq(companies.playerId, guest.playerId)); +} + +const share = content.config.loanRepaymentShare; + +describe("loan repayment on delivery", () => { + it("adds the net to credits, lowers the balance by the repayment, and records it", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 1000); + await setLoan(owner, 5000); + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ payout: 2000 }), + }); + clock.now = record.arrivalAt; + + const state = await getState(app, owner); + // The avatar flew it, so there is no pilot pay. + const repayment = Math.floor(2000 * share); + expect(state.company).toMatchObject({ + credits: 1000 + 2000 - repayment, + loanBalance: 5000 - repayment, + }); + expect(state.summaries[0]).toMatchObject({ + credits: 2000 - repayment, + loanRepayment: repayment, + }); + }); + + it("repays only what is owed when the balance is small", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 0); + await setLoan(owner, 50); + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ payout: 2000 }), + }); + clock.now = record.arrivalAt; + const state = await getState(app, owner); + expect(state.company).toMatchObject({ credits: 1950, loanBalance: 0 }); + }); + + it("puts the full amount into credits when there is no balance", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 0); + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ payout: 2000 }), + }); + clock.now = record.arrivalAt; + const state = await getState(app, owner); + expect(state.company).toMatchObject({ credits: 2000, loanBalance: 0 }); + expect(state.summaries[0]!.loanRepayment).toBe(0); + }); +}); + +describe("paying down the loan", () => { + const repay = (guest: Guest, amount: number) => + call(testApp().app, guest, "POST", "/api/loan/repay", { amount }); + + it("moves the amount from credits to the loan", async () => { + const owner = await signUpOwner(testApp().app, database().db); + await setCredits(database().db, owner.playerId, 3000); + await setLoan(owner, 5000); + const response = await repay(owner, 1200); + expect(response.statusCode).toBe(200); + expect(companyResponseSchema.parse(response.json()).company).toMatchObject({ + credits: 1800, + loanBalance: 3800, + }); + }); + + it("takes only the balance when asked to repay more", async () => { + const owner = await signUpOwner(testApp().app, database().db); + await setCredits(database().db, owner.playerId, 3000); + await setLoan(owner, 400); + const { company } = companyResponseSchema.parse((await repay(owner, 2500)).json()); + expect(company).toMatchObject({ credits: 2600, loanBalance: 0 }); + }); + + it("fails with no changes when repaying more than the player has", async () => { + const { app } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 300); + await setLoan(owner, 5000); + const response = await repay(owner, 1000); + expect(response.statusCode).toBe(409); + expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds"); + expect((await getState(app, owner)).company).toMatchObject({ credits: 300, loanBalance: 5000 }); + }); + + it("rejects a non-positive amount", async () => { + const owner = await signUpOwner(testApp().app, database().db); + expect((await repay(owner, 0)).statusCode).toBe(400); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d36d6d2..18c8880 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -114,7 +114,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [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. - [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). - [x] **Step 10A.8 — Server: accept the deal.** `POST /api/deal/accept` (`acceptDeal` in `src/game/deal.ts`) in one locked transaction: Courier at Earth (named from the seed) crewed by the avatar, Earth bay, loan = `dealLoanAmount`, tutorial off, loaner removed, deal accepted; returns the new player state. `409 deal-not-available` otherwise. Old `maybeEndTutorial` removed (replaces Step 6.7). -- [ ] Step 10A.9 — Server: loan repayment +- [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short. - [ ] Step 10A.10 — Server: goals - [ ] Step 10A.11 — Client: first launch goes straight to work - [ ] Step 10A.12 — Client: tutorial board shows the tradeoff