From 5f202884aff8dc049d99b5306b2c0690804bd1e9 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:28:11 -0400 Subject: [PATCH] Step 10K.5: rename company, and seeded default names Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 +++ apps/server/src/game/company.ts | 24 ++++++++++++++ apps/server/src/game/names.ts | 31 ++++++++++++++++++ apps/server/src/game/new-player.ts | 4 +-- apps/server/src/routes/player.ts | 7 ++++ apps/server/test/company-name.test.ts | 46 +++++++++++++++++++++++++++ memory-bank/architecture.md | 1 + memory-bank/progress.md | 2 +- packages/schema/src/requests.ts | 12 +++++++ packages/schema/src/routes.ts | 8 +++++ packages/schema/test/routes.test.ts | 1 + packages/schema/test/schemas.test.ts | 2 ++ 12 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/game/company.ts create mode 100644 apps/server/test/company-name.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 35fb8fe..7f6289a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1070,3 +1070,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The map now remembers its view per device** (the CR's "saved map camera"), saving at most every 300 ms while panning; "Reset map view" clears it. - **The intro card became the first dismissible tip**: it still shows only before the first tutorial run, but can be closed, and "Show tips again" (or a new company) brings it back. Other hints stay state-driven (they disappear on their own when no longer true). + +## Step 10K.5 + +- **Name rules live in the request schema** (`companyNameSchema`): the API trims, then checks length and characters, so a bad name is a `400 validation-error` before any database work and nothing changes. Allowed punctuation: `. , ' & ! ? -`. +- **Default names come from the world seed** (`companyName`, two word lists like ship names), as decision 0.5.75 says; every new company was "Deadhead Freight" before. Existing companies keep their name. diff --git a/apps/server/src/game/company.ts b/apps/server/src/game/company.ts new file mode 100644 index 0000000..3da176d --- /dev/null +++ b/apps/server/src/game/company.ts @@ -0,0 +1,24 @@ +import type { Company } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { companies } from "../db/schema"; + +/** Renames the player's company (decision 0.5.75); the name arrives validated and trimmed. */ +export async function renameCompany( + tx: Queryable, + playerId: string, + name: string, +): Promise { + const [updated] = await tx + .update(companies) + .set({ name }) + .where(eq(companies.playerId, playerId)) + .returning(); + if (!updated) throw new Error(`Player ${playerId} has no company`); + return { + id: updated.id, + name: updated.name, + credits: updated.credits, + loanBalance: updated.loanBalance, + }; +} diff --git a/apps/server/src/game/names.ts b/apps/server/src/game/names.ts index 54f2cc4..72e80b3 100644 --- a/apps/server/src/game/names.ts +++ b/apps/server/src/game/names.ts @@ -84,3 +84,34 @@ export function pilotName(worldSeed: Seed, n: number): string { const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`)); return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`; } + +const COMPANY_FIRST = [ + "Halcyon", + "Meridian", + "Lodestar", + "Northstar", + "Bluewater", + "Redline", + "Ironclad", + "Kestrel", + "Copperhead", + "Longhaul", + "Brightwater", + "Stonebridge", +]; +const COMPANY_LAST = [ + "Freight", + "Haulage", + "Cargo", + "Logistics", + "Shipping", + "Transit", + "Lines", + "Carriers", +]; + +/** A company's default name, stable for its world seed (decision 0.5.75). */ +export function companyName(worldSeed: Seed): string { + const rng = createRng(deriveSeed(worldSeed, "company-name")); + return `${rng.pick(COMPANY_FIRST)} ${rng.pick(COMPANY_LAST)}`; +} diff --git a/apps/server/src/game/new-player.ts b/apps/server/src/game/new-player.ts index d1279d0..f63c039 100644 --- a/apps/server/src/game/new-player.ts +++ b/apps/server/src/game/new-player.ts @@ -3,9 +3,9 @@ import { content } from "@deadhead/content"; import type { RealTime, Seed } from "@deadhead/sim"; import type { Db } from "../db/client"; import { companies, discoveredLocations, pilots, players } from "../db/schema"; +import { companyName } from "./names"; export const AVATAR_NAME = "Captain"; -export const COMPANY_NAME = "Deadhead Freight"; export interface NewPlayer { playerId: string; @@ -39,7 +39,7 @@ export async function createPlayer( await tx.insert(companies).values({ id: companyId, playerId, - name: COMPANY_NAME, + name: companyName(worldSeed), credits: content.config.startingCredits, }); await tx diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 51ff558..a114e2b 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -6,6 +6,7 @@ import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance } from "fastify"; import { acceptDeal } from "../game/deal"; import { claimGoal } from "../game/goals"; +import { renameCompany } from "../game/company"; import { repayLoan } from "../game/loan"; import { createPlayer } from "../game/new-player"; import { loadPlayerState } from "../game/player-state"; @@ -62,6 +63,12 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi }), ); + registerRoute(app, routes.renameCompany, options, async (input, { playerId }) => + withPlayer(ctx, playerId, async (tx) => ({ + company: await renameCompany(tx, playerId, input.name), + })), + ); + 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/company-name.test.ts b/apps/server/test/company-name.test.ts new file mode 100644 index 0000000..51f89c2 --- /dev/null +++ b/apps/server/test/company-name.test.ts @@ -0,0 +1,46 @@ +import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { companyName } from "../src/game/names"; +import { useTestDatabase } from "./db"; +import { call, getState, signUp, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const rename = async (name: string) => { + const guest = await signUp(testApp().app); + const before = (await getState(testApp().app, guest)).company.name; + const response = await call(testApp().app, guest, "POST", "/api/company/name", { name }); + return { guest, before, response }; +}; + +describe("company name (Step 10K.5)", () => { + it("starts with a name made from the world seed", async () => { + const guest = await signUp(testApp().app); + const state = await getState(testApp().app, guest); + expect(state.company.name.length).toBeGreaterThanOrEqual(3); + expect(companyName(42)).toBe(companyName(42)); + const names = new Set(Array.from({ length: 30 }, (_, i) => companyName(i))); + expect(names.size).toBeGreaterThan(1); + }); + + it("saves a valid name, trimmed", async () => { + const { guest, response } = await rename(" Rosa & Sons Haulage! "); + expect(response.statusCode).toBe(200); + expect(companyResponseSchema.parse(response.json()).company.name).toBe("Rosa & Sons Haulage!"); + expect((await getState(testApp().app, guest)).company.name).toBe("Rosa & Sons Haulage!"); + }); + + it.each([ + ["too short", "Ab"], + ["too long", "A".repeat(33)], + ["blank after trimming", " "], + ["disallowed characters", "Freight