diff --git a/DECISIONS.md b/DECISIONS.md index 7f6289a..379de9d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/game/new-company.ts b/apps/server/src/game/new-company.ts new file mode 100644 index 0000000..62d49d9 --- /dev/null +++ b/apps/server/src/game/new-company.ts @@ -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 { + 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, + }); +} diff --git a/apps/server/src/game/new-player.ts b/apps/server/src/game/new-player.ts index f63c039..e27ea4b 100644 --- a/apps/server/src/game/new-player.ts +++ b/apps/server/src/game/new-player.ts @@ -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 { + 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, + }); +} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index a114e2b..9278d5d 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -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), diff --git a/apps/server/test/new-company.test.ts b/apps/server/test/new-company.test.ts new file mode 100644 index 0000000..7df09d3 --- /dev/null +++ b/apps/server/test/new-company.test.ts @@ -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)); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 4f55177..daf7289 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9fad936..a1ef33d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 ``, 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 diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 873b69b..8893842 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -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", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 9f5978b..edee38f 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -24,6 +24,7 @@ describe("route catalog", () => { "spendSkillPoint", "acknowledgeSummaries", "acceptDeal", + "newCompany", "renameCompany", "repayLoan", "claimGoal",