Step 10A.9: apply loan repayment and add paying down the loan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:17:42 -04:00
parent 63e52ea4aa
commit 45ab0f3339
6 changed files with 189 additions and 11 deletions

View File

@ -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. - **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. - **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). - **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.

View File

@ -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<Company> {
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,
};
}

View File

@ -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 * 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 * it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
* exactly once: the status update is conditional, and effects are applied only * 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. Returns how many dispatches were resolved.
@ -34,6 +35,9 @@ export async function resolveArrivedDispatches(
.from(discoveredLocations) .from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId)); .where(eq(discoveredLocations.playerId, playerId));
const discoveredIds = new Set(discovered.map((d) => d.locationId)); 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; let resolvedCount = 0;
for (const dispatch of due) { for (const dispatch of due) {
@ -41,14 +45,7 @@ export async function resolveArrivedDispatches(
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch( const result = resolveDispatch(
// The loan balance is applied in Step 10A.9. { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now },
{
record: dispatch.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds,
loanBalance: 0,
now,
},
content, content,
); );
if (result.status !== "resolved") continue; if (result.status !== "resolved") continue;
@ -61,10 +58,14 @@ export async function resolveArrivedDispatches(
.returning({ id: dispatches.id }); .returning({ id: dispatches.id });
if (claimed.length === 0) continue; if (claimed.length === 0) continue;
resolvedCount++; resolvedCount++;
loanBalance -= resolution.loanRepayment;
await tx await tx
.update(companies) .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)); .where(eq(companies.playerId, playerId));
await tx await tx

View File

@ -5,6 +5,7 @@ import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node"; import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal"; import { acceptDeal } from "../game/deal";
import { repayLoan } from "../game/loan";
import { createPlayer } from "../game/new-player"; import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state"; import { loadPlayerState } from "../game/player-state";
import { acknowledgeSummaries } from "../game/summaries"; import { acknowledgeSummaries } from "../game/summaries";
@ -57,4 +58,10 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
return loadPlayerState(tx, playerId); return loadPlayerState(tx, playerId);
}), }),
); );
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
company: await repayLoan(tx, playerId, input.amount),
})),
);
} }

View File

@ -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);
});
});

View File

@ -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.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.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). - [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.10 — Server: goals
- [ ] Step 10A.11 — Client: first launch goes straight to work - [ ] Step 10A.11 — Client: first launch goes straight to work
- [ ] Step 10A.12 — Client: tutorial board shows the tradeoff - [ ] Step 10A.12 — Client: tutorial board shows the tradeoff