Step 10K.6: start a new company on the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:32:47 -04:00
parent 5f202884af
commit 531bb77fea
9 changed files with 196 additions and 23 deletions

View File

@ -1075,3 +1075,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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: `. , ' & ! ? -`. - **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. - **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.
## Step 10K.6
- **One starting-state function** (`createStartingState`: company, start-discovered locations, avatar at Earth) serves guest creation and a new company, so they can't drift apart; the test compares a reset company with a brand-new guest field by field.
- **The player row survives** (its id, sign-ins and later its backup code); only its tutorial fields reset, with a world seed guaranteed to differ.
- **No job cleanup is needed:** arrival jobs look up the player's due dispatches when they fire, and the deleted ones are simply gone.
- **The new company also gets a new default name** (from the new seed); a renamed company's name does not carry over, since everything else starts over too.

View File

@ -0,0 +1,59 @@
import { randomInt, randomUUID } from "node:crypto";
import type { RealTime } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
claimedGoals,
companies,
contractBoards,
discoveredLocations,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { requirePlayer } from "./fleet";
import { createStartingState } from "./new-player";
/**
* "Start a new company" (decision 0.5.74), inside the caller's player-locked
* transaction: deletes everything the company owns, then builds the starting
* state exactly as guest creation does, with a new world seed. The player
* (and so their sign-ins and backup code) stays.
*/
export async function newCompany(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
const player = await requirePlayer(tx, playerId);
// Children before parents; ship upgrades go with their ships.
await tx.delete(claimedGoals).where(eq(claimedGoals.playerId, playerId));
await tx.delete(acceptedContracts).where(eq(acceptedContracts.playerId, playerId));
await tx.delete(dispatches).where(eq(dispatches.playerId, playerId));
await tx.delete(contractBoards).where(eq(contractBoards.playerId, playerId));
await tx.delete(discoveredLocations).where(eq(discoveredLocations.playerId, playerId));
await tx.delete(bays).where(eq(bays.playerId, playerId));
await tx.delete(pilots).where(eq(pilots.playerId, playerId));
await tx.delete(ships).where(eq(ships.playerId, playerId));
await tx.delete(companies).where(eq(companies.playerId, playerId));
// A 32-bit seed (decision 0.5.10), never the old one.
let worldSeed = randomInt(0, 2 ** 32);
while (worldSeed === player.worldSeed) worldSeed = randomInt(0, 2 ** 32);
await tx
.update(players)
.set({
worldSeed,
tutorialActive: true,
loanerLocationId: "earth",
tutorialRunsCompleted: 0,
dealAccepted: false,
})
.where(eq(players.id, playerId));
await createStartingState(tx, {
playerId,
companyId: randomUUID(),
avatarId: randomUUID(),
worldSeed,
now,
});
}

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { RealTime, Seed } from "@deadhead/sim"; import type { RealTime, Seed } from "@deadhead/sim";
import type { Db } from "../db/client"; import type { Db, Queryable } from "../db/client";
import { companies, discoveredLocations, pilots, players } from "../db/schema"; import { companies, discoveredLocations, pilots, players } from "../db/schema";
import { companyName } from "./names"; import { companyName } from "./names";
@ -36,6 +36,22 @@ export async function createPlayer(
loanerLocationId: "earth", loanerLocationId: "earth",
createdAt: now, createdAt: now,
}); });
await createStartingState(tx, { playerId, companyId, avatarId, worldSeed, now });
});
return { playerId, companyId, avatarId };
}
/**
* A player's starting company (CR-001 tutorial state): starting credits, every
* location that starts discovered, and the avatar at Earth beside the loaner.
* Shared by guest creation and "Start a new company" (decision 0.5.74).
*/
export async function createStartingState(
tx: Queryable,
ids: { playerId: string; companyId: string; avatarId: string; worldSeed: Seed; now: RealTime },
): Promise<void> {
const { playerId, companyId, avatarId, worldSeed, now } = ids;
await tx.insert(companies).values({ await tx.insert(companies).values({
id: companyId, id: companyId,
playerId, playerId,
@ -57,7 +73,4 @@ export async function createPlayer(
locationId: "earth", locationId: "earth",
createdAt: now, createdAt: now,
}); });
});
return { playerId, companyId, avatarId };
} }

View File

@ -8,6 +8,7 @@ import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals"; import { claimGoal } from "../game/goals";
import { renameCompany } from "../game/company"; import { renameCompany } from "../game/company";
import { repayLoan } from "../game/loan"; import { repayLoan } from "../game/loan";
import { newCompany } from "../game/new-company";
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 { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries"; import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
@ -63,6 +64,13 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
}), }),
); );
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await newCompany(tx, playerId, now);
return loadPlayerState(tx, playerId, now);
}),
);
registerRoute(app, routes.renameCompany, options, async (input, { playerId }) => registerRoute(app, routes.renameCompany, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({ withPlayer(ctx, playerId, async (tx) => ({
company: await renameCompany(tx, playerId, input.name), company: await renameCompany(tx, playerId, input.name),

View File

@ -0,0 +1,77 @@
import { content } from "@deadhead/content";
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { players } from "../src/db/schema";
import { resolveArrivalsJob } from "../src/jobs/definitions";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** The parts of a state that must match a brand-new guest's (ids differ). */
const shape = (s: PlayerState) => ({
credits: s.company.credits,
loanBalance: s.company.loanBalance,
ships: s.ships.length,
bays: s.bays.length,
pilots: s.pilots.map((p) => ({
isAvatar: p.isAvatar,
locationId: p.locationId,
level: p.level,
assignedShipId: p.assignedShipId,
})),
discovered: s.locations.filter((l) => l.discovered).map((l) => l.id),
tutorial: s.tutorial,
activeDispatches: s.activeDispatches.length,
summaries: s.summaries.length,
goals: s.goals,
});
describe("start a new company (Step 10K.6)", () => {
it("turns a mid-game company back into a fresh guest start, with a new world seed", async () => {
const { app, clock } = testApp();
const { db } = database();
const owner = await signUpOwner(app, db);
await setCredits(db, owner.playerId, 50_000);
await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
await call(app, owner, "POST", `/api/ships/${owner.shipId}/upgrades`, { upgradeId: "engines" });
const flight = await insertDispatch(db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
const [before] = await db.select().from(players).where(eq(players.id, owner.playerId));
const response = await call(app, owner, "POST", "/api/company/new", {});
expect(response.statusCode).toBe(200);
const fresh = playerStateSchema.parse(response.json());
const guest = await signUp(app);
expect(shape(fresh)).toEqual(shape(await getState(app, guest)));
expect(fresh.tutorial).toMatchObject({ loanerLocationId: "earth", runsCompleted: 0 });
expect(fresh.company.credits).toBe(content.config.startingCredits);
expect(shape(fresh).discovered.sort()).toEqual(["earth", "moon"]);
const [after] = await db.select().from(players).where(eq(players.id, owner.playerId));
expect(after!.worldSeed).not.toBe(before!.worldSeed);
expect(after!.id).toBe(before!.id);
// The old flight's arrival job finds nothing and changes nothing.
clock.now = flight.arrivalAt + 1000;
await expect(
resolveArrivalsJob({ db, clock: clock.read }, { playerId: owner.playerId }),
).resolves.toBeUndefined();
expect(shape(await getState(app, owner))).toEqual(shape(fresh));
});
});

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 | | `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 | | `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 | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes |
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes | | `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes | | `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |

View File

@ -257,7 +257,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [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.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`. - [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`.
- [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`. - [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 - [x] **Step 10K.6 — Server: start a new company.** Route `newCompany` (`POST /api/company/new`, `{}` → player state) runs `newCompany` in `withPlayer` (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls `createStartingState`, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: `new-company.test.ts` (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8.
- [ ] Step 10K.7 — Client: rename and new company flows - [ ] Step 10K.7 — Client: rename and new company flows
- [ ] Step 10K.8 — Server: backup code and restore - [ ] Step 10K.8 — Server: backup code and restore
- [ ] Step 10K.9 — Client: backup and restore flows - [ ] Step 10K.9 — Client: backup and restore flows

View File

@ -152,6 +152,13 @@ export const routes = {
response: playerStateSchema, response: playerStateSchema,
auth: true, auth: true,
}, },
newCompany: {
method: "POST",
path: "/api/company/new",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
},
renameCompany: { renameCompany: {
method: "POST", method: "POST",
path: "/api/company/name", path: "/api/company/name",

View File

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