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:
parent
5f202884af
commit
531bb77fea
|
|
@ -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: `. , ' & ! ? -`.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
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 { companyName } from "./names";
|
||||
|
||||
|
|
@ -36,28 +36,41 @@ export async function createPlayer(
|
|||
loanerLocationId: "earth",
|
||||
createdAt: now,
|
||||
});
|
||||
await tx.insert(companies).values({
|
||||
id: companyId,
|
||||
playerId,
|
||||
name: companyName(worldSeed),
|
||||
credits: content.config.startingCredits,
|
||||
});
|
||||
await tx
|
||||
.insert(discoveredLocations)
|
||||
.values(
|
||||
content.locations
|
||||
.filter((l) => l.startsDiscovered)
|
||||
.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })),
|
||||
);
|
||||
await tx.insert(pilots).values({
|
||||
id: avatarId,
|
||||
playerId,
|
||||
name: AVATAR_NAME,
|
||||
isAvatar: true,
|
||||
locationId: "earth",
|
||||
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({
|
||||
id: companyId,
|
||||
playerId,
|
||||
name: companyName(worldSeed),
|
||||
credits: content.config.startingCredits,
|
||||
});
|
||||
await tx
|
||||
.insert(discoveredLocations)
|
||||
.values(
|
||||
content.locations
|
||||
.filter((l) => l.startsDiscovered)
|
||||
.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })),
|
||||
);
|
||||
await tx.insert(pilots).values({
|
||||
id: avatarId,
|
||||
playerId,
|
||||
name: AVATAR_NAME,
|
||||
isAvatar: true,
|
||||
locationId: "earth",
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { acceptDeal } from "../game/deal";
|
|||
import { claimGoal } from "../game/goals";
|
||||
import { renameCompany } from "../game/company";
|
||||
import { repayLoan } from "../game/loan";
|
||||
import { newCompany } from "../game/new-company";
|
||||
import { createPlayer } from "../game/new-player";
|
||||
import { loadPlayerState } from "../game/player-state";
|
||||
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 }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
company: await renameCompany(tx, playerId, input.name),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 |
|
||||
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | 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 |
|
||||
|
|
|
|||
|
|
@ -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.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`.
|
||||
- [ ] 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.8 — Server: backup code and restore
|
||||
- [ ] Step 10K.9 — Client: backup and restore flows
|
||||
|
|
|
|||
|
|
@ -152,6 +152,13 @@ export const routes = {
|
|||
response: playerStateSchema,
|
||||
auth: true,
|
||||
},
|
||||
newCompany: {
|
||||
method: "POST",
|
||||
path: "/api/company/new",
|
||||
request: emptyRequestSchema,
|
||||
response: playerStateSchema,
|
||||
auth: true,
|
||||
},
|
||||
renameCompany: {
|
||||
method: "POST",
|
||||
path: "/api/company/name",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ describe("route catalog", () => {
|
|||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
"newCompany",
|
||||
"renameCompany",
|
||||
"repayLoan",
|
||||
"claimGoal",
|
||||
|
|
|
|||
Loading…
Reference in New Issue