Step 10K.5: rename company, and seeded default names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:28:11 -04:00
parent bc29c515eb
commit 5f202884af
12 changed files with 140 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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),

View File

@ -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 <script>"],
["emoji", "Space 🚀 Co"],
])("rejects a name that is %s, changing nothing", async (_why, name) => {
const { guest, before, response } = await rename(name);
expect(response.statusCode).toBe(400);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
expect((await getState(testApp().app, guest)).company.name).toBe(before);
});
});

View File

@ -62,6 +62,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |

View File

@ -256,7 +256,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [x] **Step 10K.2 — Client: settings screen and entry point.** A gear link (`aria-label="Settings"`, new `gear` icon) at the right of the header opens `/settings` (a route inside the layout, not a nav tab). `screens/Settings.tsx`: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when `import.meta.env.PROD` is false. Remaining items arrive with their steps. Test: `settings.test.tsx`.
- [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets `<html data-reduced-motion>`, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults.
- [x] **Step 10K.4 — Client: reset map view and show tips again.** The map camera starts from the saved view and saves itself (throttled to 300 ms) as the player pans and zooms; `resetMapView()` restores the default and clears the saved view (Settings and the map's "Reset view"). The tutorial intro card is now a one-time tip (`tutorial-intro`) with a dismiss button; "Show tips again" clears dismissed tips. Test: `help-settings.test.tsx`.
- [ ] Step 10K.5 — Server: rename company
- [x] **Step 10K.5 — Server: rename company.** `companyNameSchema` (trimmed; 3–32 of letters, digits, spaces and `. , ' & ! ? -`) and route `renameCompany` (`POST /api/company/name`, `{ name }` → `{ company }`), run in `withPlayer`. New companies get a default name from their world seed (`companyName(seed)`, e.g. "Lodestar Haulage") instead of the fixed "Deadhead Freight". Player state already carried the name. Test: `company-name.test.ts`.
- [ ] Step 10K.6 — Server: start a new company
- [ ] Step 10K.7 — Client: rename and new company flows
- [ ] Step 10K.8 — Server: backup code and restore

View File

@ -81,3 +81,15 @@ export const acknowledgeSummariesResponseSchema = z.object({
export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() });
export const claimGoalRequestSchema = z.object({ goalId: idSchema });
export const companyResponseSchema = z.object({ company: companySchema });
/**
* A company name (decision 0.5.75): trimmed, 3–32 characters of letters,
* digits, spaces and basic punctuation.
*/
export const companyNameSchema = z
.string()
.trim()
.min(3, "At least 3 characters")
.max(32, "At most 32 characters")
.regex(/^[A-Za-z0-9 .,'&!?-]+$/, "Letters, digits, spaces and . , ' & ! ? - only");
export const renameCompanyRequestSchema = z.object({ name: companyNameSchema });

View File

@ -17,6 +17,7 @@ import {
hirePilotRequestSchema,
leaseBayRequestSchema,
pilotResponseSchema,
renameCompanyRequestSchema,
repayLoanRequestSchema,
shipResponseSchema,
spendSkillPointRequestSchema,
@ -151,6 +152,13 @@ export const routes = {
response: playerStateSchema,
auth: true,
},
renameCompany: {
method: "POST",
path: "/api/company/name",
request: renameCompanyRequestSchema,
response: companyResponseSchema,
auth: true,
},
repayLoan: {
method: "POST",
path: "/api/loan/repay",

View File

@ -24,6 +24,7 @@ describe("route catalog", () => {
"spendSkillPoint",
"acknowledgeSummaries",
"acceptDeal",
"renameCompany",
"repayLoan",
"claimGoal",
].sort(),

View File

@ -9,6 +9,7 @@ import type { z } from "zod";
import {
acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema,
renameCompanyRequestSchema,
startUpgradeRequestSchema,
assignPilotRequestSchema,
baySchema,
@ -183,6 +184,7 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
{ ...ship, shop: null },
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
],
["rename company request", renameCompanyRequestSchema, { name: "Kell & Co." }, { name: "Hi" }],
[
"start upgrade request",
startUpgradeRequestSchema,