Step 10K.5: rename company, and seeded default names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc29c515eb
commit
5f202884af
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ describe("route catalog", () => {
|
|||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
"renameCompany",
|
||||
"repayLoan",
|
||||
"claimGoal",
|
||||
].sort(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue