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:
parent
63e52ea4aa
commit
45ab0f3339
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue