Compare commits

..

No commits in common. "cab0a1cb8133c85e29114f97d6f6f2eda62df117" and "621eecb73789732820635d0740e9ab82be3c8eda" have entirely different histories.

55 changed files with 67 additions and 3300 deletions

View File

@ -1065,60 +1065,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **User report:** "On a phone screen the credits and time line can't be read" (screenshot at about 430 px): the credits chip was drawn over the DEADHEAD wordmark. The wordmark showed from 420 px, and 10K.2's gear plus 10K.3's device-locale clock ("2145-07-11 7:18 PM") made the right side wider than the space left.
- **Fix:** the wordmark now shows from 640 px (`sm`), the logo side clips instead of overflowing, the game clock shows only the time below 640 px (the date returns above), chips sit a little closer, and the gear is 32 px on phones. The gear icon is now a cog (it read as a sun).
- **Checked** in headless Chrome at 375, 390 and 430 px with a 1,234,567 cr balance and a 12-hour clock: no overlapping header items and no horizontal scroll (`docs/screenshots/header-phone-390.png`).
## Step 10K.4
- **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.
## 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.
## Step 10K.7
- **Client and server share one name rule** (`companyNameSchema` from `@deadhead/schema`), so the inline message is the same check the server will apply.
- **The confirmation is case-sensitive**: the typed text must equal the company name exactly.
- **A new company clears dismissed tips** so the tutorial intro card greets the restart, as the CR's "Contracts with the intro card" asks.
## Step 10K.8
- **Restoring creates a new guest sign-in for the device** and links it to the player in `player_logins`; session lookup checks the player's own user, then those links. Better Auth's public API can't start a session for an existing user, and this way several devices share a player safely (decision 0.5.73). The device's previous guest company is left behind, as the CR warns.
- **Code format:** 20 characters from a 31-character alphabet without 0/O/1/I/L (`23456789ABCDEFGHJKMNPQRSTUVWXYZ`), shown `XXXXX-XXXXX-XXXXX-XXXXX`: about 99 bits, above the 80 the CR asks. Input ignores case, spaces and dashes.
- **Hashing:** SHA-256 of the normalized code, with a unique index; the code is random enough that a slow password hash adds nothing, and lookup stays one indexed query.
- **Endpoints:** `GET` says whether a code exists; `POST` makes a new one, shown once, and the old one stops working. The server never returns an existing code.
- **Errors:** a wrong code is a generic `409 restore-failed` (no hint whether it was close); too many failures are `429 rate-limited`. 409 rather than 401, because the client treats 401 as "signed out".
- **Rate limit:** 5 failed attempts per 15 minutes, counted per IP and per device id (the client sends `x-device-id`), in memory per server process, which is enough for the single-instance base game (Phase 13 can move it to Postgres).
## Step 10K.9
- **The code is shown once, when made**, like the server promises: reopening "Back up company" with a code on file offers to generate a new one (with the warning) rather than showing the old code, which the server no longer knows.
- **Restore from the start screen never warns** (there's no company yet); in Settings it warns only when the current company is past the tutorial, as the CR asks.
- **Success replaces the cached player state with the restored one** and refreshes boards, then goes to HQ; the server has already switched the device's session cookie.
- **The device id is a random UUID in local storage**, sent only with restore requests for rate limiting; it isn't tied to the player.
## Step 10K.10
- **Version is the web package's version, bumped to 0.10.0** (it was 0.0.0) to mark the Phase 10 build; **build is the git short hash** at build time ("dev" when built outside git), injected with Vite `define`. Tests use fixed stand-ins.
- **"Current screen" in debug info is the last screen before Settings**: the button lives in Settings, so the useful answer is where the player was when they opened it. The layout records it.
- **Player state now includes `playerId`** for debug info; it was only returned at sign-up.
## Step 10K.11
- **"Advance" adds to the offset** (`devAdvanceClock`), where the existing clock-offset route sets it outright; the client doesn't know the server's offset, and E2E tests keep using clock-offset unchanged.
- **The developer routes are in the catalog** (so the client calls them typed, as the CR's §6 asks) but are only registered outside production, like clock-offset; in production all three return 404 (tested). They require a session so they act on the caller's player.
- **The clock is shared:** advancing it moves time for every player on that server, which is fine for development servers.
## Step 10K.12
- **Amendment "12.x"** is recorded on Step 12.3 and applies to all the end-to-end steps (12.3–12.6).
- **Step 11.2's offline rule is noted for Phase 11**, where the offline behaviour is built; server-backed settings already fail with the usual error when offline.
- **The two-device manual check was scripted** with separate sessions; a real second phone or a private window is the user's check.

View File

@ -1,11 +0,0 @@
CREATE TABLE "player_logins" (
"user_id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_hash" text;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_created_at" bigint;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_backup_code_hash_unique" UNIQUE("backup_code_hash");

File diff suppressed because it is too large Load Diff

View File

@ -57,13 +57,6 @@
"when": 1791164145228,
"tag": "0007_cr010_veteran_pilot",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1791171500580,
"tag": "0008_cr011_backup_codes",
"breakpoints": true
}
]
}

View File

@ -12,7 +12,6 @@ import type { AppContext } from "./routes/context";
import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player";
import { registerDevRoutes } from "./routes/dev";
import { registerTestClockRoute } from "./routes/test-clock";
export interface AppDeps {
@ -66,10 +65,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, ctx);
if (config.env !== "production") {
registerTestClockRoute(app, clock);
registerDevRoutes(app, ctx, clock);
}
if (config.env !== "production") registerTestClockRoute(app, clock);
return app;
}

View File

@ -38,9 +38,6 @@ export const players = pgTable("players", {
/** The one-time deal has been accepted (decision 0.5.30). */
dealAccepted: boolean("deal_accepted").notNull().default(false),
createdAt: time("created_at").notNull(),
/** SHA-256 of the player's backup code (decision 0.5.72); null until one is made. */
backupCodeHash: text("backup_code_hash").unique(),
backupCodeCreatedAt: time("backup_code_created_at"),
});
export const companies = pgTable("companies", {
@ -236,17 +233,3 @@ export const claimedGoals = pgTable(
},
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);
/**
* Extra sign-ins for a player (decisions 0.5.72, 0.5.73): each device that
* restores with a backup code gets its own guest user, linked here.
*/
export const playerLogins = pgTable("player_logins", {
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
createdAt: time("created_at").notNull(),
});

View File

@ -1,96 +0,0 @@
import { createHash, randomInt } from "node:crypto";
import type { RealTime } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { players } from "../db/schema";
/**
* Backup codes (decision 0.5.72): 20 characters from 31 that can't be mistaken
* for each other (no 0/O, 1/I/L), shown as four groups of five: about 99 bits.
* Only a SHA-256 of the normalized code is stored; the code itself is random
* enough that a slow hash would add nothing.
*/
export const BACKUP_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const GROUPS = 4;
const GROUP_LENGTH = 5;
export function generateBackupCode(): string {
const groups = Array.from({ length: GROUPS }, () =>
Array.from(
{ length: GROUP_LENGTH },
() => BACKUP_ALPHABET[randomInt(BACKUP_ALPHABET.length)],
).join(""),
);
return groups.join("-");
}
/** The code without spaces or dashes, in capitals: how players type it doesn't matter. */
export function normalizeBackupCode(code: string): string {
return code.toUpperCase().replace(/[\s-]/g, "");
}
export function hashBackupCode(code: string): string {
return createHash("sha256").update(normalizeBackupCode(code)).digest("hex");
}
/** Makes a new code for the player, replacing any old one; returns it once. */
export async function createBackupCode(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<{ code: string; createdAt: RealTime }> {
const code = generateBackupCode();
await tx
.update(players)
.set({ backupCodeHash: hashBackupCode(code), backupCodeCreatedAt: now })
.where(eq(players.id, playerId));
return { code, createdAt: now };
}
export async function backupCodeStatus(tx: Queryable, playerId: string) {
const [row] = await tx
.select({ hash: players.backupCodeHash, createdAt: players.backupCodeCreatedAt })
.from(players)
.where(eq(players.id, playerId));
return { hasCode: Boolean(row?.hash), createdAt: row?.hash ? (row.createdAt ?? null) : null };
}
/** The player a backup code belongs to, or null. */
export async function playerForBackupCode(tx: Queryable, code: string): Promise<string | null> {
if (normalizeBackupCode(code).length === 0) return null;
const [row] = await tx
.select({ id: players.id })
.from(players)
.where(eq(players.backupCodeHash, hashBackupCode(code)));
return row?.id ?? null;
}
/**
* Failed restore attempts per key (an IP, a device), in memory: at most
* `limit` failures per `windowMs` for any key (decision 0.5.72).
*/
export class RestoreLimiter {
private readonly failures = new Map<string, { count: number; resetAt: RealTime }>();
constructor(
readonly limit = 5,
readonly windowMs = 15 * 60_000,
) {}
/** Whether any of the keys has used up its attempts. */
blocked(keys: readonly string[], now: RealTime): boolean {
return keys.some((key) => {
const entry = this.failures.get(key);
return entry !== undefined && entry.resetAt > now && entry.count >= this.limit;
});
}
fail(keys: readonly string[], now: RealTime): void {
for (const key of keys) {
const entry = this.failures.get(key);
if (!entry || entry.resetAt <= now)
this.failures.set(key, { count: 1, resetAt: now + this.windowMs });
else entry.count++;
}
}
}

View File

@ -1,24 +0,0 @@
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,34 +84,3 @@ 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

@ -1,59 +0,0 @@
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,11 +1,11 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { RealTime, Seed } from "@deadhead/sim";
import type { Db, Queryable } from "../db/client";
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;
@ -36,41 +36,28 @@ export async function createPlayer(
loanerLocationId: "earth",
createdAt: now,
});
await createStartingState(tx, { playerId, companyId, avatarId, worldSeed, now });
await tx.insert(companies).values({
id: companyId,
playerId,
name: COMPANY_NAME,
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,
});
});
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,
});
}

View File

@ -67,7 +67,6 @@ export async function loadPlayerState(
);
return {
playerId,
company: {
id: company.id,
name: company.name,

View File

@ -3,7 +3,7 @@ import { fromNodeHeaders } from "better-auth/node";
import type { FastifyRequest } from "fastify";
import type { Auth } from "../auth";
import type { Db } from "../db/client";
import { playerLogins, players } from "../db/schema";
import { players } from "../db/schema";
import { ApiError } from "./errors";
/** The signed-in player's id, or a 401 `unauthorized` error. */
@ -19,12 +19,6 @@ export async function requirePlayerId(
.select({ id: players.id })
.from(players)
.where(eq(players.userId, session.user.id));
if (player) return player.id;
// A device signed in with a backup code (decision 0.5.72).
const [login] = await db
.select({ playerId: playerLogins.playerId })
.from(playerLogins)
.where(eq(playerLogins.userId, session.user.id));
if (!login) throw new ApiError(401, "unauthorized", "No player for this account.");
return login.playerId;
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
return player.id;
}

View File

@ -1,37 +0,0 @@
import { routes } from "@deadhead/schema";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import type { OffsetClock } from "../clock";
import { companies } from "../db/schema";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/**
* Developer tools (CR-011): fast-forward the clock and add credits. Only
* registered outside production, like the clock-offset route (decision 0.5.18).
*/
export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void {
registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => {
clock.setOffset(clock.offsetMs() + input.advanceMs);
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
});
registerRoute(app, routes.devAddCredits, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => {
const [company] = await tx
.update(companies)
.set({ credits: sql`${companies.credits} + ${input.amount}` })
.where(eq(companies.playerId, playerId))
.returning();
return {
company: {
id: company!.id,
name: company!.name,
credits: company!.credits,
loanBalance: company!.loanBalance,
},
};
}),
);
}

View File

@ -6,19 +6,10 @@ import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals";
import {
backupCodeStatus,
createBackupCode,
playerForBackupCode,
RestoreLimiter,
} from "../game/backup";
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";
import { playerLogins } from "../db/schema";
import { ApiError } from "../http/errors";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
@ -71,63 +62,6 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
}),
);
registerRoute(app, routes.getBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx) => backupCodeStatus(tx, playerId)),
);
registerRoute(app, routes.createBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => createBackupCode(tx, playerId, now)),
);
// Restoring signs this device in as the code's player (decisions 0.5.72, 0.5.73).
const limiter = new RestoreLimiter();
registerRoute(app, routes.restoreCompany, options, async (input, { request, reply }) => {
const now = clock();
const device = request.headers["x-device-id"];
const keys = [`ip:${request.ip}`, ...(typeof device === "string" ? [`device:${device}`] : [])];
if (limiter.blocked(keys, now)) {
throw new ApiError(
429,
"rate-limited",
"Too many attempts. Wait a few minutes and try again.",
);
}
const playerId = await playerForBackupCode(db, input.code);
if (!playerId) {
limiter.fail(keys, now);
throw new ApiError(
409,
"restore-failed",
"That code didn't open a company. Check it and try again.",
);
}
// A fresh guest sign-in for this device (any old session is left behind),
// linked to the code's player.
const headers = fromNodeHeaders(request.headers);
headers.delete("cookie");
const signIn = await auth.api.signInAnonymous({ headers, returnHeaders: true });
if (!signIn.response)
throw new ApiError(500, "internal-error", "Could not sign this device in.");
await db
.insert(playerLogins)
.values({ userId: signIn.response.user.id, playerId, createdAt: now });
reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId, now) };
});
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),
})),
);
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
company: await repayLoan(tx, playerId, input.amount),

View File

@ -1,106 +0,0 @@
import {
backupCodeResponseSchema,
createGuestResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { sql } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { BACKUP_ALPHABET, normalizeBackupCode } from "../src/game/backup";
import { setCredits, useTestDatabase } from "./db";
import { call, cookieFrom, getState, signUp, signUpOwner, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const makeCode = async (guest: Guest) =>
backupCodeResponseSchema.parse(
(await call(testApp().app, guest, "POST", "/api/backup-code", {})).json(),
).code;
/** Restores on a new device (no cookie); `device` sets the device-id header. */
const restore = (code: string, device = "device-a", ip = "10.0.0.1") =>
testApp().app.inject({
method: "POST",
url: "/api/restore",
headers: { "x-device-id": device },
remoteAddress: ip,
payload: { code },
});
describe("backup codes (Step 10K.8)", () => {
it("makes a 20-character code in four groups, without look-alike characters", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
expect(code).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}$/);
for (const ch of normalizeBackupCode(code)) expect(BACKUP_ALPHABET).toContain(ch);
expect(code).not.toMatch(/[01OIL]/);
expect((await call(testApp().app, guest, "GET", "/api/backup-code")).json()).toMatchObject({
hasCode: true,
});
});
it("restores the same player on a second device, and both see the same state", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 4321);
const code = await makeCode(owner);
const response = await restore(code.toLowerCase().replaceAll("-", " "));
expect(response.statusCode).toBe(200);
const body = createGuestResponseSchema.parse(response.json());
expect(body.playerId).toBe(owner.playerId);
const second: Guest = { ...owner, cookie: cookieFrom(response.headers["set-cookie"]) };
expect(second.cookie).not.toBe(owner.cookie);
await call(app, second, "POST", "/api/company/name", { name: "Second Device Co" });
expect((await getState(app, owner)).company).toMatchObject({
name: "Second Device Co",
credits: 4321,
});
});
it("stops the old code working when a new one is made", async () => {
const guest = await signUp(testApp().app);
const first = await makeCode(guest);
const second = await makeCode(guest);
expect(second).not.toBe(first);
expect((await restore(first)).statusCode).toBe(409);
expect((await restore(second)).statusCode).toBe(200);
});
it("fails a wrong code with a generic error, and limits attempts", async () => {
const wrong = await restore("ABCDE-FGHJK-MNPQR-STUVW", "device-b", "10.0.0.2");
expect(wrong.statusCode).toBe(409);
expect(errorResponseSchema.parse(wrong.json()).error.code).toBe("restore-failed");
for (let i = 0; i < 4; i++) await restore("NOPE", "device-b", "10.0.0.2");
const limited = await restore("NOPE", "device-b", "10.0.0.2");
expect(limited.statusCode).toBe(429);
expect(errorResponseSchema.parse(limited.json()).error.code).toBe("rate-limited");
// The same device from another network is still limited.
expect((await restore("NOPE", "device-b", "10.0.0.3")).statusCode).toBe(429);
});
it("never stores the plain code", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
const plain = normalizeBackupCode(code);
const rows = await database().db.execute(
sql`select row_to_json(p)::text as row from players p`,
);
for (const { row } of rows.rows as { row: string }[]) {
expect(row).not.toContain(code);
expect(row).not.toContain(plain);
}
});
it("still restores the player after they start a new company", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const code = await makeCode(owner);
await call(testApp().app, owner, "POST", "/api/company/new", {});
const response = await restore(code, "device-c", "10.0.0.4");
expect(createGuestResponseSchema.parse(response.json())).toMatchObject({
playerId: owner.playerId,
state: { tutorial: { runsCompleted: 0 } },
});
});
});

View File

@ -1,46 +0,0 @@
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

@ -1,73 +0,0 @@
import { companyResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("developer tools (Step 10K.11)", () => {
it("advances the server clock", async () => {
const guest = await signUp(testApp().app);
const before = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
const response = await call(testApp().app, guest, "POST", "/api/test/advance-clock", {
advanceMs: 600_000,
});
expect(response.statusCode).toBe(200);
const after = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
expect(after - before).toBeGreaterThanOrEqual(600_000);
});
it("adds credits", async () => {
const guest = await signUp(testApp().app);
const start = (await getState(testApp().app, guest)).company.credits;
const response = await call(testApp().app, guest, "POST", "/api/test/add-credits", {
amount: 10_000,
});
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(start + 10_000);
});
it("lands every flight when advanced to the last arrival", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const flight = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(1);
await call(app, owner, "POST", "/api/test/advance-clock", {
advanceMs: flight.arrivalAt - clock.now + 1000,
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(0);
});
it("does not exist in production", async () => {
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
database: database(),
jobs: createRecordingQueue(),
});
for (const [url, payload] of [
["/api/test/clock-offset", { offsetMs: 1 }],
["/api/test/advance-clock", { advanceMs: 1000 }],
["/api/test/add-credits", { amount: 10_000 }],
] as const) {
const response = await app.inject({ method: "POST", url, payload });
expect(response.statusCode, url).toBe(404);
}
await app.close();
});
});

View File

@ -50,7 +50,7 @@ export function useTestApp(database: () => Database): () => TestApp {
return () => current!;
}
export function cookieFrom(setCookie: string | string[] | undefined): string {
function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}

View File

@ -41,7 +41,6 @@ describe("database migrations", () => {
"discovered_locations",
"dispatches",
"pilots",
"player_logins",
"players",
"session",
"ship_upgrades",

View File

@ -1,77 +0,0 @@
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

@ -1,6 +1,6 @@
{
"name": "@deadhead/web",
"version": "0.10.0",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -6,7 +6,6 @@ import {
type RouteRequest,
type RouteResponse,
} from "@deadhead/schema";
import { deviceId } from "./device";
/** The server answered with its documented error format. */
export class ApiError extends Error {
@ -75,11 +74,7 @@ export function createApiClient(options: ApiClientOptions = {}): ApiClient {
const response = await doFetch(baseUrl + url, {
method: route.method,
credentials: "include",
headers: {
...(body ? { "content-type": "application/json" } : {}),
// Restore attempts are rate-limited per device (CR-011).
...(name === "restoreCompany" ? { "x-device-id": deviceId() } : {}),
},
headers: body ? { "content-type": "application/json" } : {},
body,
});

View File

@ -1,20 +0,0 @@
const DEVICE_KEY = "deadhead:device-id";
let fallback: string | null = null;
/**
* A random id for this device, kept in local storage: the server rate-limits
* restore attempts per device (decision 0.5.72). Without storage it lasts for
* the page's lifetime.
*/
export function deviceId(): string {
try {
const saved = localStorage.getItem(DEVICE_KEY);
if (saved) return saved;
const id = crypto.randomUUID();
localStorage.setItem(DEVICE_KEY, id);
return id;
} catch {
fallback ??= Math.random().toString(36).slice(2) + Date.now().toString(36);
return fallback;
}
}

View File

@ -1,6 +0,0 @@
declare const __APP_VERSION__: string;
declare const __APP_BUILD__: string;
/** The game's version and build (git commit), baked in by Vite (CR-011). */
export const APP_VERSION: string = __APP_VERSION__;
export const APP_BUILD: string = __APP_BUILD__;

View File

@ -3,7 +3,6 @@ import { Link, NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui";
import { useTrackScreen } from "./lastScreen";
export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
@ -26,7 +25,6 @@ export function Layout({
banner,
attention = {},
}: LayoutProps) {
useTrackScreen();
return (
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">

View File

@ -1,17 +0,0 @@
import { useEffect } from "react";
import { useLocation } from "react-router";
let lastScreen = "/";
/** Records the screen the player is on, ignoring Settings itself (for debug info, CR-011). */
export function useTrackScreen(): void {
const { pathname } = useLocation();
useEffect(() => {
if (pathname !== "/settings") lastScreen = pathname;
}, [pathname]);
}
/** The last screen before Settings, e.g. "/fleet". */
export function lastScreenPath(): string {
return lastScreen;
}

View File

@ -1,5 +1,4 @@
import { create } from "zustand";
import { usePreferences } from "../state/preferences";
import type { MapPoint } from "./projection";
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
@ -21,28 +20,7 @@ const FIT = 0.92;
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
/** The map view, starting where the player left it on this device (CR-011). */
export const useCamera = create<Camera>(
() => usePreferences.getState().mapCamera ?? DEFAULT_CAMERA,
);
// Save the view as it changes, at most a few times a second.
let saveTimer: ReturnType<typeof setTimeout> | null = null;
useCamera.subscribe((camera) => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
usePreferences.getState().set({ mapCamera: camera });
}, 300);
});
/** Back to the default view, and forget the saved one ("Reset map view"). */
export function resetMapView(): void {
useCamera.setState(DEFAULT_CAMERA);
if (saveTimer) clearTimeout(saveTimer);
saveTimer = null;
usePreferences.getState().set({ mapCamera: null });
}
export const useCamera = create<Camera>(() => DEFAULT_CAMERA);
/** Pixels per map unit at this zoom. */
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {

View File

@ -1,47 +0,0 @@
import { useState } from "react";
import { APP_BUILD, APP_VERSION } from "../build-info";
import { serverNow, useClock } from "../clock/clock";
import { lastScreenPath } from "../layout/lastScreen";
import { useLoadedPlayer } from "../state/actions";
import { Button } from "../ui";
/** The text "Copy debug info" puts on the clipboard (CR-011). */
export function debugInfo(playerId: string): string {
return [
"Deadhead: Space Freight Tycoon",
`Version: ${APP_VERSION}`,
`Build: ${APP_BUILD}`,
`Player: ${playerId}`,
`Server time: ${new Date(serverNow()).toISOString()}`,
`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`,
`Screen: ${lastScreenPath()}`,
].join("\n");
}
/** Settings → About: version and build, and debug info for bug reports. */
export function About() {
const state = useLoadedPlayer();
const [copied, setCopied] = useState(false);
return (
<div className="space-y-2 text-sm">
<div className="flex items-baseline justify-between gap-3">
<span className="text-ink-400">Deadhead: Space Freight Tycoon</span>
<span aria-label="Version" className="font-display text-ink-100 tabular-nums">
{APP_VERSION} · build {APP_BUILD}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
void navigator.clipboard
?.writeText(debugInfo(state.playerId))
.then(() => setCopied(true))
.catch(() => setCopied(false));
}}
>
{copied ? "Debug info copied" : "Copy debug info"}
</Button>
</div>
);
}

View File

@ -1,184 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { errorMessage } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Button, Sheet } from "../ui";
const BACKUP_KEY = ["backup-code"] as const;
/** "Back up company": make a code (shown once), or replace the existing one (decision 0.5.72). */
export function BackupCompany() {
const api = useApi();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const status = useQuery({
queryKey: BACKUP_KEY,
queryFn: () => api.call("getBackupCode", {}),
enabled: open,
});
const create = useMutation({
mutationFn: () => api.call("createBackupCode", {}),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: BACKUP_KEY }),
});
const code = create.data?.code;
const close = () => {
setOpen(false);
setCopied(false);
create.reset();
};
const copy = (text: string) => {
void navigator.clipboard
?.writeText(text)
.then(() => setCopied(true))
.catch(() => setCopied(false));
};
let body;
if (code) {
body = (
<>
<p
aria-label="Backup code"
className="mt-4 rounded-xl bg-space-950/80 p-4 text-center font-mono text-2xl font-bold tracking-widest text-amber-200 ring-1 ring-amber-400/40 select-all"
>
{code}
</p>
<p className="mt-3 text-sm text-ink-200">
Keep this somewhere safe. Anyone with this code can open your company.
</p>
<Button block className="mt-3" onClick={() => copy(code)}>
{copied ? "Copied" : "Copy"}
</Button>
</>
);
} else if (status.isPending) {
body = <p className="mt-4 text-sm text-ink-400">Checking…</p>;
} else if (status.data?.hasCode) {
body = (
<>
<p className="mt-3 text-sm text-ink-200">
You already have a backup code. It is only shown when it is made, so if you lost it, make
a new one.
</p>
<p className="mt-2 text-sm text-amber-200">
Your old code will stop working as soon as you generate a new one.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
Generate a new code
</Button>
</>
);
} else {
body = (
<>
<p className="mt-3 text-sm text-ink-200">
A backup code lets you open this company on any device, or get it back if this one is
lost. No email or password.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
Create a backup code
</Button>
</>
);
}
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Back up company
</Button>
{open && (
<Sheet titleId="backup-title" title="Back up your company" eyebrow="Backup code">
{body}
{(create.isError || status.isError) && (
<p role="alert" className="mt-2 text-sm text-rose-300">
{errorMessage(create.error ?? status.error)}
</p>
)}
<Button variant="secondary" block className="mt-3" onClick={close}>
Done
</Button>
</Sheet>
)}
</>
);
}
/**
* "Restore a company": enter a backup code to open that company on this
* device. `pastTutorial` warns that this device's own company is left behind.
*/
export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [code, setCode] = useState("");
const restore = useMutation({
mutationFn: () => api.call("restoreCompany", { code }),
onSuccess: async ({ state }) => {
queryClient.setQueryData(PLAYER_STATE_KEY, state);
await queryClient.invalidateQueries({ queryKey: ["board"] });
setOpen(false);
void navigate("/");
},
});
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Restore a company
</Button>
{open && (
<Sheet titleId="restore-title" title="Restore a company" eyebrow="Backup code" layer="z-30">
{pastTutorial && (
<p
role="note"
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
>
This device&apos;s current company will be left behind. Back it up first if you want
to keep it.
</p>
)}
<form
className="mt-3 space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (code.trim()) restore.mutate();
}}
>
<label className="block text-sm">
<span className="text-ink-300">Backup code</span>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
className="mt-1 w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono tracking-widest text-ink-50 focus:border-amber-400 focus:outline-none"
/>
</label>
{restore.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(restore.error)}
</p>
)}
<div className="flex gap-2">
<Button type="submit" block disabled={!code.trim() || restore.isPending}>
Restore
</Button>
<Button variant="secondary" block onClick={() => setOpen(false)}>
Cancel
</Button>
</div>
</form>
</Sheet>
)}
</>
);
}

View File

@ -1,145 +0,0 @@
import { companyNameSchema } from "@deadhead/schema";
import { useState } from "react";
import { useNavigate } from "react-router";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Button, Sheet } from "../ui";
const inputClass =
"w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
/** The company name, editable in place with the server's own rules (decision 0.5.75). */
export function CompanyName() {
const state = useLoadedPlayer();
const rename = useGameAction("renameCompany");
const [draft, setDraft] = useState<string | null>(null);
if (draft === null) {
return (
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-ink-400">Company name</span>
<span className="flex items-center gap-2">
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
<Button size="sm" variant="ghost" onClick={() => setDraft(state.company.name)}>
Rename
</Button>
</span>
</div>
);
}
const check = companyNameSchema.safeParse(draft);
const problem = check.success ? null : check.error.issues[0]?.message;
return (
<form
className="space-y-2 text-sm"
onSubmit={(e) => {
e.preventDefault();
if (!check.success) return;
rename.mutate({ name: check.data }, { onSuccess: () => setDraft(null) });
}}
>
<label className="block">
<span className="text-ink-400">Company name</span>
<input
aria-invalid={problem ? true : undefined}
aria-describedby="company-name-problem"
value={draft}
maxLength={40}
onChange={(e) => setDraft(e.target.value)}
className={`${inputClass} mt-1`}
/>
</label>
{problem && (
<p id="company-name-problem" className="text-rose-300">
{problem}
</p>
)}
{rename.isError && (
<p role="alert" className="text-rose-300">
{errorMessage(rename.error)}
</p>
)}
<div className="flex gap-2">
<Button size="sm" type="submit" disabled={!check.success || rename.isPending}>
Save
</Button>
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
Cancel
</Button>
</div>
</form>
);
}
/** "Start a new company": a confirmation that needs the company's name typed in (decision 0.5.74). */
export function NewCompany() {
const state = useLoadedPlayer();
const start = useGameAction("newCompany");
const navigate = useNavigate();
const showTipsAgain = usePreferences((p) => p.showTipsAgain);
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const name = state.company.name;
return (
<>
<Button variant="danger" size="sm" onClick={() => setOpen(true)}>
Start a new company
</Button>
{open && (
<Sheet titleId="new-company-title" title="Start a new company?" tone="rose" layer="z-30">
<p className="mt-2 text-sm text-ink-200">
Everything in {name} will be lost: ships, pilots, HQs, credits, the loan and your
progress. You start again from the first company run. This can&apos;t be undone.
</p>
<label className="mt-3 block text-sm">
<span className="text-ink-300">
Type <strong className="text-ink-50">{name}</strong> to confirm
</span>
<input
value={typed}
onChange={(e) => setTyped(e.target.value)}
className={`${inputClass} mt-1`}
autoComplete="off"
/>
</label>
{start.isError && (
<p role="alert" className="mt-2 text-sm text-rose-300">
{errorMessage(start.error)}
</p>
)}
<div className="mt-4 flex gap-2">
<Button
variant="danger"
block
disabled={typed !== name || start.isPending}
onClick={() =>
start.mutate(
{},
{
onSuccess: () => {
showTipsAgain();
void navigate("/contracts");
},
},
)
}
>
Start over
</Button>
<Button
variant="secondary"
block
onClick={() => {
setOpen(false);
setTyped("");
}}
>
Cancel
</Button>
</div>
</Sheet>
)}
</>
);
}

View File

@ -1,75 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useApi } from "../api/context";
import { serverNow, syncClock } from "../clock/clock";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Button } from "../ui";
const MINUTE = 60_000;
/**
* Settings → Developer (non-production builds only, CR-011): fast-forward the
* server clock, add credits, or land every flight now.
*/
export function DevTools() {
const api = useApi();
const queryClient = useQueryClient();
const state = useLoadedPlayer();
const refresh = async () => {
await syncClock(api);
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] });
};
const advance = useMutation({
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
onSuccess: refresh,
});
const credits = useMutation({
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
onSuccess: refresh,
});
const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt));
const busy = advance.isPending || credits.isPending;
const error = advance.error ?? credits.error;
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(10 * MINUTE)}
>
Advance 10 minutes
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(60 * MINUTE)}
>
Advance 1 hour
</Button>
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
Add 10,000 credits
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || lastArrival <= serverNow()}
onClick={() =>
advance.mutate(Math.max(1000, Math.ceil(lastArrival - serverNow()) + 1000))
}
>
Finish all flights
</Button>
</div>
{error && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(error)}
</p>
)}
</div>
);
}

View File

@ -7,7 +7,7 @@ import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format";
import { distanceAt } from "../game";
import { resetMapView } from "../map/camera";
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
@ -36,7 +36,7 @@ export function MapScreen() {
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
<button
type="button"
onClick={resetMapView}
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
>
Reset view

View File

@ -1,9 +1,5 @@
import type { ReactNode } from "react";
import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings";
import { BackupCompany, RestoreCompany } from "./BackupSettings";
import { CompanyName, NewCompany } from "./CompanySettings";
import { DevTools } from "./DevTools";
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui";
@ -23,12 +19,7 @@ export function Settings() {
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company">
<CompanyName />
<div className="flex flex-wrap gap-2">
<BackupCompany />
<RestoreCompany pastTutorial={state.tutorial === null} />
<NewCompany />
</div>
<Row label="Company name">{state.company.name}</Row>
</Section>
<Section title="Display">
@ -66,7 +57,14 @@ export function Settings() {
<Section title="Help">
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={resetMapView}>
<Button
variant="secondary"
size="sm"
onClick={() => {
useCamera.setState(DEFAULT_CAMERA);
prefs.set({ mapCamera: null });
}}
>
Reset map view
</Button>
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
@ -76,12 +74,12 @@ export function Settings() {
</Section>
<Section title="About">
<About />
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
</Section>
{isDevBuild() && (
<Section title="Developer">
<DevTools />
<p className="text-sm text-ink-400">Development build tools.</p>
</Section>
)}
</Screen>
@ -100,6 +98,15 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-ink-400">{label}</span>
<span className="text-right text-ink-100">{children}</span>
</div>
);
}
/** A labelled radio group of a few options, styled as a segmented control. */
function Choice<T extends string>(props: {
label: string;

View File

@ -3,7 +3,6 @@ import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Icon } from "../ui";
import { RestoreCompany } from "./BackupSettings";
/** First launch: create a guest account (decision: guests first, accounts later). */
export function StartScreen() {
@ -59,9 +58,6 @@ export function StartScreen() {
<Icon name="send" className="size-5" />
{start.isPending ? "Starting…" : "Start your company"}
</Button>
<div className="relative">
<RestoreCompany pastTutorial={false} />
</div>
{start.isError && (
<p role="alert" className="relative text-sm text-rose-300">
Could not start a company. Check your connection and try again.

View File

@ -7,15 +7,11 @@ import { formatCredits, formatDuration, locationName } from "../format";
import { goodName } from "../components/Goods";
import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
/** The id of the tutorial intro card in dismissed tips. */
export const INTRO_TIP = "tutorial-intro";
/** The company board during the tutorial (Step 9.3). */
export function TutorialBoard() {
const api = useApi();
@ -29,9 +25,6 @@ export function TutorialBoard() {
const runInFlight = state.tutorial?.runInFlight ?? false;
const loaner = loanerModel(content);
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
// A one-time tip: dismissible, and back with "Show tips again" (CR-011).
const introDismissed = usePreferences((p) => p.dismissedTips.includes(INTRO_TIP));
const dismissTip = usePreferences((p) => p.dismissTip);
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
if (board.isError) {
@ -47,7 +40,7 @@ export function TutorialBoard() {
return (
<div className="space-y-4">
{firstRun && !introDismissed && (
{firstRun && (
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
<div>
@ -56,14 +49,6 @@ export function TutorialBoard() {
</p>
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
</div>
<button
type="button"
aria-label="Dismiss tip"
onClick={() => dismissTip(INTRO_TIP)}
className="ml-auto self-start rounded-md p-1 text-ink-400 hover:text-ink-100"
>
<Icon name="close" className="size-4" />
</button>
</Panel>
)}
<p className="text-sm text-ink-300">

View File

@ -1,29 +0,0 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render";
describe("about and debug info (Step 10K.10)", () => {
it("shows the version and copies debug info with every field", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/fleet");
await userEvent.click(await screen.findByRole("link", { name: "Settings" }));
const about = await screen.findByRole("region", { name: "About" });
// vitest.config.ts stands in for the build-time version and commit.
expect(within(about).getByLabelText("Version")).toHaveTextContent("0.0.0-test · build test");
await userEvent.click(within(about).getByRole("button", { name: "Copy debug info" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const text = (writeText.mock.calls[0] as unknown as [string])[0];
expect(text).toContain("Version: 0.0.0-test");
expect(text).toContain("Build: test");
expect(text).toContain("Player: player-1");
expect(text).toMatch(/Server time: \d{4}-\d{2}-\d{2}T/);
// The app syncs its own clock, so expect whatever offset it holds now.
expect(text).toContain(`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`);
expect(text).toContain("Screen: /fleet");
});
});

View File

@ -1,124 +0,0 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../src/api/client";
import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
const CODE = "ABCDE-FGHJK-MNPQR-STUVW";
const owner = () => samplePlayerState({ tutorial: null });
const restored = () =>
samplePlayerState({
tutorial: null,
company: { id: "c2", name: "Restored Freight", credits: 77_000, loanBalance: 0 },
});
async function openRestore() {
await userEvent.click(await screen.findByRole("button", { name: "Restore a company" }));
return screen.getByRole("dialog", { name: "Restore a company" });
}
describe("backup and restore (Step 10K.9)", () => {
afterEach(() => vi.unstubAllGlobals());
it("shows a new code grouped, and Copy puts it on the clipboard", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
renderApp(
fakeApi({
getPlayerState: owner,
getBackupCode: () => ({ hasCode: false, createdAt: null }),
createBackupCode: () => ({ code: CODE, createdAt: 1 }),
}),
"/settings",
);
await userEvent.click(await screen.findByRole("button", { name: "Back up company" }));
const sheet = screen.getByRole("dialog", { name: "Back up your company" });
await userEvent.click(
await within(sheet).findByRole("button", { name: "Create a backup code" }),
);
expect(await within(sheet).findByLabelText("Backup code")).toHaveTextContent(CODE);
expect(sheet).toHaveTextContent("Anyone with this code can open your company.");
await userEvent.click(within(sheet).getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(CODE);
expect(await within(sheet).findByRole("button", { name: "Copied" })).toBeInTheDocument();
});
it("warns about an existing code before replacing it", async () => {
renderApp(
fakeApi({
getPlayerState: owner,
getBackupCode: () => ({ hasCode: true, createdAt: 1 }),
}),
"/settings",
);
await userEvent.click(await screen.findByRole("button", { name: "Back up company" }));
const sheet = screen.getByRole("dialog", { name: "Back up your company" });
expect(
await within(sheet).findByRole("button", { name: "Generate a new code" }),
).toBeInTheDocument();
expect(sheet).toHaveTextContent("Your old code will stop working");
});
it("warns that a company past the tutorial is left behind, and not during the tutorial", async () => {
const { unmount } = renderApp(fakeApi({ getPlayerState: owner }), "/settings");
expect(await openRestore()).toHaveTextContent("current company will be left behind");
unmount();
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/settings");
expect(await openRestore()).not.toHaveTextContent("left behind");
});
it("restores and goes to HQ", async () => {
let done = false;
const api = fakeApi({
getPlayerState: () => (done ? restored() : owner()),
restoreCompany: () => {
done = true;
return { playerId: "p2", state: restored() };
},
});
renderApp(api, "/settings");
const sheet = await openRestore();
await userEvent.type(within(sheet).getByRole("textbox"), CODE.toLowerCase());
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("restoreCompany", { code: CODE.toLowerCase() }),
);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(screen.getByLabelText("Credits")).toHaveTextContent("77,000 cr");
});
it("shows a failed restore's error and stays put", async () => {
renderApp(
fakeApi({
getPlayerState: owner,
restoreCompany: () => {
throw new ApiError(409, "restore-failed", "That code didn't open a company.");
},
}),
"/settings",
);
const sheet = await openRestore();
await userEvent.type(within(sheet).getByRole("textbox"), "WRONG");
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
expect(await within(sheet).findByRole("alert")).toHaveTextContent("didn't open a company");
expect(screen.getByRole("dialog", { name: "Restore a company" })).toBeInTheDocument();
});
it("offers restore on the start screen too", async () => {
let done = false;
renderApp(
fakeApi({
getPlayerState: () => (done ? restored() : unauthorized()),
restoreCompany: () => {
done = true;
return { playerId: "p2", state: restored() };
},
}),
);
const sheet = await openRestore();
expect(sheet).not.toHaveTextContent("left behind");
await userEvent.type(within(sheet).getByRole("textbox"), CODE);
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
});
});

View File

@ -1,67 +0,0 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const owner = () =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "Kell Haulage", credits: 9000, loanBalance: 0 },
});
describe("company settings (Step 10K.7)", () => {
afterEach(() => usePreferences.getState().set(DEFAULT_PREFERENCES));
it("renames in place, checking the name as you type", async () => {
const api = fakeApi({
getPlayerState: owner,
renameCompany: ({ name }) => ({ company: { ...owner().company, name } }),
});
renderApp(api, "/settings");
const company = await screen.findByRole("region", { name: "Company" });
await userEvent.click(within(company).getByRole("button", { name: "Rename" }));
const input = within(company).getByRole("textbox");
await userEvent.clear(input);
await userEvent.type(input, "Ab");
expect(within(company).getByText("At least 3 characters")).toBeInTheDocument();
expect(within(company).getByRole("button", { name: "Save" })).toBeDisabled();
await userEvent.type(input, "le Freight ");
await userEvent.click(within(company).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("renameCompany", { name: "Able Freight" }),
);
});
it("starts over only after the exact company name is typed, then opens the tutorial", async () => {
let reset = false;
const api = fakeApi({
getPlayerState: () => (reset ? samplePlayerState() : owner()),
getTutorialBoard: () => sampleTutorialBoard(),
newCompany: () => {
reset = true;
return samplePlayerState();
},
});
usePreferences.getState().dismissTip("tutorial-intro");
renderApp(api, "/settings");
await userEvent.click(await screen.findByRole("button", { name: "Start a new company" }));
const sheet = screen.getByRole("dialog", { name: "Start a new company?" });
expect(sheet).toHaveTextContent("Everything in Kell Haulage will be lost");
const confirm = within(sheet).getByRole("button", { name: "Start over" });
expect(confirm).toBeDisabled();
const input = within(sheet).getByRole("textbox");
await userEvent.type(input, "kell haulage");
expect(confirm).toBeDisabled();
await userEvent.clear(input);
await userEvent.type(input, "Kell Haulage");
expect(confirm).toBeEnabled();
await userEvent.click(confirm);
await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {}));
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
});
});

View File

@ -1,57 +0,0 @@
import type { Dispatch } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
describe("developer tools in Settings (Step 10K.11)", () => {
it("advances the clock, adds credits and finishes flights", async () => {
const now = Date.now();
const flight = {
id: "d1",
shipId: "loaner",
pilotId: "avatar",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: true,
departureAt: now - 60_000,
arrivalAt: now + 5 * 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
} as unknown as Dispatch;
const api = fakeApi({
getPlayerState: () => samplePlayerState({ activeDispatches: [flight] }),
devAdvanceClock: ({ advanceMs }) => ({ offsetMs: advanceMs, realTime: now + advanceMs }),
devAddCredits: ({ amount }) => ({
company: { id: "co1", name: "D", credits: amount, loanBalance: 0 },
}),
});
renderApp(api, "/settings");
const dev = await screen.findByRole("region", { name: "Developer" });
await userEvent.click(within(dev).getByRole("button", { name: "Advance 10 minutes" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 600_000 }),
);
await userEvent.click(within(dev).getByRole("button", { name: "Advance 1 hour" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 3_600_000 }),
);
await userEvent.click(within(dev).getByRole("button", { name: "Add 10,000 credits" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("devAddCredits", { amount: 10_000 }));
await userEvent.click(within(dev).getByRole("button", { name: "Finish all flights" }));
await waitFor(() => {
const advances = api.call.mock.calls.filter(([name]) => name === "devAdvanceClock");
const last = advances.at(-1)![1] as { advanceMs: number };
// To just past the last arrival (5 minutes away), give or take the test's own runtime.
expect(last.advanceMs).toBeGreaterThan(5 * 60_000 - 5_000);
expect(last.advanceMs).toBeLessThan(5 * 60_000 + 5_000);
});
});
});

View File

@ -1,51 +0,0 @@
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CAMERA, pan, useCamera } from "../src/map/camera";
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
describe("help settings (Step 10K.4)", () => {
afterEach(() => {
vi.useRealTimers();
act(() => {
useCamera.setState(DEFAULT_CAMERA);
usePreferences.getState().set(DEFAULT_PREFERENCES);
});
});
it("saves the map view, and Reset map view restores the default and forgets it", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
act(() => useCamera.setState((c) => pan({ ...c, zoom: 3 }, 120, -80)));
act(() => vi.advanceTimersByTime(400));
expect(usePreferences.getState().mapCamera).toEqual({ zoom: 3, panX: 120, panY: -80 });
vi.useRealTimers();
renderApp(
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
"/settings",
);
await userEvent.click(await screen.findByRole("button", { name: "Reset map view" }));
expect(useCamera.getState()).toEqual(DEFAULT_CAMERA);
expect(usePreferences.getState().mapCamera).toBeNull();
});
it("brings a dismissed tip back with Show tips again", async () => {
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => sampleTutorialBoard(),
}),
"/contracts",
);
const intro = await screen.findByLabelText("Intro");
await userEvent.click(within(intro).getByRole("button", { name: "Dismiss tip" }));
expect(screen.queryByLabelText("Intro")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("link", { name: "Settings" }));
await userEvent.click(await screen.findByRole("button", { name: "Show tips again" }));
const nav = screen.getByRole("navigation", { name: "Main" });
await userEvent.click(within(nav).getByRole("link", { name: /Jobs/ }));
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
});
});

View File

@ -61,7 +61,6 @@ export function shipExtras(
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
playerId: "player-1",
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
ships: [],
pilots: [

View File

@ -1,7 +1,5 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { defineConfig, type Plugin } from "vite";
@ -25,26 +23,7 @@ function omitSourceArt(): Plugin {
};
}
/** Version from package.json and the git commit, shown in Settings → About (CR-011). */
function buildInfo() {
const { version } = JSON.parse(
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
) as {
version: string;
};
let build = "dev";
try {
build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
// Not a git checkout (e.g. a tarball build): keep "dev".
}
return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) };
}
export default defineConfig({
define: buildInfo(),
plugins: [react(), tailwindcss(), omitSourceArt()],
server: {
// Same-domain API in development too (decision 0.5.20). The server listens on

View File

@ -3,8 +3,6 @@ import { defineProject } from "vitest/config";
export default defineProject({
plugins: [react()],
// Stand-ins for the build-time version and commit (see vite.config.ts).
define: { __APP_VERSION__: JSON.stringify("0.0.0-test"), __APP_BUILD__: JSON.stringify("test") },
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

View File

@ -34,7 +34,7 @@ Living reference for how the code is organised. Update it after any database sch
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotPay` (hired pilot's profit share, CR-010), `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
### Errors
@ -62,13 +62,6 @@ 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 |
| `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes |
| `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes |
| `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no |
| `devAdvanceClock` | `POST /api/test/advance-clock` | `advanceMs` | `{ offsetMs, realTime }` | yes; non-production only |
| `devAddCredits` | `POST /api/test/add-credits` | `amount` | `{ company }` | yes; non-production only |
| `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 |
@ -81,7 +74,6 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `player_logins` | Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) | PK `user_id` |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) |

View File

@ -320,7 +320,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Test:** A test asserts every endpoint has request and response schemas and that no two endpoints share a method and path.
---
- **Amended by CR-011:** The catalog gains `renameCompany`, `newCompany`, `getBackupCode`, `createBackupCode`, `restoreCompany` (no session), and the non-production `devAdvanceClock` and `devAddCredits` (Steps 10K.5–10K.11).
## Phase 5 — Server Foundation
@ -339,7 +338,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3).
- **Amended by CR-004:** The pilots table gains `duty_hours` and `rest_ends_at` (Step 10D.4).
- **Amended by CR-009:** `ship_upgrades` table and, on ships, in-shop-until and the latest shop job (Step 10I.4).
- **Amended by CR-011:** Players store a backup code hash (unique) and its creation time; `player_logins` links extra sign-ins to a player; the company name was already stored (Steps 10K.5, 10K.8).
### Step 5.4 — Test database harness
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
@ -451,7 +449,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Create the React app in `apps/web` with Tailwind, a basic layout (header with credits and game clock, main area, bottom navigation for mobile), and routing for: HQ, Contracts, Fleet, Pilots, Market, Locations.
- **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll.
- **Amended by CR-005:** The header becomes a HUD bar and navigation becomes icon tabs: a bottom bar on phones, a left rail from 768 px, with attention badges (decision 0.5.49, Step 10E.3).
- **Amended by CR-011:** The header gains a Settings gear (Step 10K.2).
### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated.
@ -465,7 +462,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session.
- **Test:** Component test: clicking the button calls create-guest and navigates to HQ. Reloading with a stored session skips the start screen.
- **Amended by CR-001:** After creating the guest, the client lands on Contracts, not HQ (Step 10A.11).
- **Amended by CR-011:** The start screen gains "Restore a company" under "Start your company" (Step 10K.9).
### Step 8.5 — Player state store
- Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes.
@ -633,7 +629,6 @@ Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settin
- **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled.
---
- **Amended by CR-011:** Settings that need the server (rename, backup, restore, new company, developer tools) are disabled while offline; display and help settings still work.
## Phase 12 — Balance Harness and End-to-End Tests
@ -657,7 +652,6 @@ Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settin
- Playwright test against a real server and test database, with the server clock controllable via a test-only setting: start as guest → fly tutorial runs (advancing the clock) until affordable → buy a Courier → lease Earth bay → tutorial ends.
- **Test:** The E2E test passes and final state shows one owned ship, one Earth bay, and tutorial mode off.
- **Amended by CR-001:** Rewritten: guest → four tutorial runs → accept deal → state shows one owned Courier at Earth crewed by the avatar, one Earth bay, a loan balance, and tutorial off → one contract dispatched and resolved with a repayment applied.
- **Amended by CR-011:** End-to-end tests (12.3–12.6) may use "Start a new company" and the developer tools (advance clock, add credits, finish flights) to reset and fast-forward.
### Step 12.4 — End-to-end: offline progress
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen.

View File

@ -255,12 +255,12 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [x] **Step 10K.1 — Client: device preferences store.** `state/preferences.ts`: `usePreferences` (Zustand) with clock (`locale`/`12h`/`24h`), countdown (`real`/`game`/`both`), reduced-motion override (null = follow the device), dismissed tips and saved map camera; `set`, `dismissTip`, `showTipsAgain`. Saved under `deadhead:preferences:v1`; loading validates each field and falls back to defaults; every read and write is wrapped. `reducedMotionOn(prefs)`. Test: `preferences.test.ts` (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory).
- [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`.
- [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.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.
- [x] **Step 10K.7 — Client: rename and new company flows.** `screens/CompanySettings.tsx`: the company name has "Rename", an in-place field checked as you type with the server's `companyNameSchema` (message under the field, Save disabled until valid; saves the trimmed name). "Start a new company" opens a confirmation explaining what is lost; "Start over" stays disabled until the exact company name is typed (case-sensitive), then calls `newCompany`, clears dismissed tips and goes to Contracts, where the intro card shows. Test: `company-settings.test.tsx`.
- [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company).
- [x] **Step 10K.9 — Client: backup and restore flows.** `screens/BackupSettings.tsx`: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends `x-device-id` (`api/device.ts`) on restore. Test: `backup-ui.test.tsx`.
- [x] **Step 10K.10 — Client: About and debug info.** Vite bakes in `__APP_VERSION__` (web package version, now 0.10.0) and `__APP_BUILD__` (git short hash, "dev" outside git) — `src/build-info.ts`. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains `playerId`. Test: `about.test.tsx`.
- [x] **Step 10K.11 — Client and server: developer tools (non-production only).** Catalog routes `devAdvanceClock` (`POST /api/test/advance-clock`, `{ advanceMs }`) and `devAddCredits` (`POST /api/test/add-credits`, `{ amount }`), registered with the clock-offset route only when NODE_ENV isn't production (`routes/dev.ts`). Settings → Developer (`screens/DevTools.tsx`): Advance 10 minutes, Advance 1 hour, Add 10,000 credits, Finish all flights (advances to just past the latest arrival); afterwards the client re-syncs its clock and reloads state and boards. Tests: `dev-tools.test.ts` (server, including 404s in production) and `dev-tools.test.tsx` (client).
- [x] **Step 10K.12 — Plan amendments and regression.** CR-011 §6 amendments added to plan steps 4.2, 5.3, 8.1, 8.4, 11.2 and 12.3 (for 12.3–12.6); §0.3 and §0.5.71–76 were set with the CR. Typecheck, lint, full suite with coverage and the web build are green. Scripted manual check against a live server (scratchpad `cr011-smoke.mjs`, two cookie jars as two devices): a renamed company with 50,500 cr was backed up on one and restored on the other (same player, name and credits); starting a new company on the second device showed the fresh company on the first after a refresh; the same backup code still opened it. Screenshot: `docs/screenshots/10k12-settings-{phone,desktop}.png`. Also in this phase: the phone header fix from the user's report. **Phase 10K complete — stopped for review.**
- [ ] Step 10K.4 — Client: reset map view and show tips again
- [ ] Step 10K.5 — Server: rename company
- [ ] 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
- [ ] Step 10K.9 — Client: backup and restore flows
- [ ] Step 10K.10 — Client: About and debug info
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
- [ ] Step 10K.12 — Plan amendments and regression

View File

@ -201,8 +201,6 @@ export const goalStateSchema = z.object({
export type GoalState = z.infer<typeof goalStateSchema>;
export const playerStateSchema = z.object({
/** This player (debug info, CR-011). */
playerId: idSchema,
company: companySchema,
ships: z.array(shipSchema),
pilots: z.array(pilotSchema),

View File

@ -35,8 +35,6 @@ export const SERVER_ERROR_CODES = [
"unknown-goal",
"goal-not-ready",
"goal-already-claimed",
"restore-failed",
"rate-limited",
] as const;
export type ErrorCode =

View File

@ -81,44 +81,3 @@ 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 });
/** Whether the player has a backup code, and since when (CR-011). */
export const backupCodeStatusSchema = z.object({
hasCode: z.boolean(),
createdAt: timeSchema.nullable(),
});
/** A newly made backup code: shown once (decision 0.5.72). */
export const backupCodeResponseSchema = z.object({
code: z.string().min(1),
createdAt: timeSchema,
});
/** A backup code typed by the player; spaces, dashes and case don't matter. */
export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) });
/** Developer tools, non-production only (CR-011). */
export const devAdvanceClockRequestSchema = z.object({
advanceMs: z
.number()
.int()
.positive()
.max(30 * 24 * 3_600_000),
});
export const devAdvanceClockResponseSchema = z.object({
offsetMs: z.number().int(),
realTime: timeSchema,
});
export const devAddCreditsRequestSchema = z.object({
amount: z.number().int().positive().max(10_000_000),
});

View File

@ -17,13 +17,6 @@ import {
hirePilotRequestSchema,
leaseBayRequestSchema,
pilotResponseSchema,
backupCodeResponseSchema,
devAddCreditsRequestSchema,
devAdvanceClockRequestSchema,
devAdvanceClockResponseSchema,
backupCodeStatusSchema,
renameCompanyRequestSchema,
restoreRequestSchema,
repayLoanRequestSchema,
shipResponseSchema,
spendSkillPointRequestSchema,
@ -158,56 +151,6 @@ export const routes = {
response: playerStateSchema,
auth: true,
},
getBackupCode: {
method: "GET",
path: "/api/backup-code",
request: emptyRequestSchema,
response: backupCodeStatusSchema,
auth: true,
},
createBackupCode: {
method: "POST",
path: "/api/backup-code",
request: emptyRequestSchema,
response: backupCodeResponseSchema,
auth: true,
},
restoreCompany: {
method: "POST",
path: "/api/restore",
request: restoreRequestSchema,
response: createGuestResponseSchema,
auth: false,
},
/** Non-production only (CR-011): not registered when NODE_ENV is production. */
devAdvanceClock: {
method: "POST",
path: "/api/test/advance-clock",
request: devAdvanceClockRequestSchema,
response: devAdvanceClockResponseSchema,
auth: true,
},
devAddCredits: {
method: "POST",
path: "/api/test/add-credits",
request: devAddCreditsRequestSchema,
response: companyResponseSchema,
auth: true,
},
newCompany: {
method: "POST",
path: "/api/company/new",
request: emptyRequestSchema,
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,13 +24,6 @@ describe("route catalog", () => {
"spendSkillPoint",
"acknowledgeSummaries",
"acceptDeal",
"getBackupCode",
"createBackupCode",
"restoreCompany",
"devAdvanceClock",
"devAddCredits",
"newCompany",
"renameCompany",
"repayLoan",
"claimGoal",
].sort(),
@ -63,9 +56,8 @@ describe("route catalog", () => {
}
});
it("requires a session everywhere except server time, guest creation and restore", () => {
it("requires a session everywhere except server time and guest creation", () => {
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
// Restoring signs a device in, so it runs without a session (CR-011).
expect(open.sort()).toEqual(["createGuest", "getServerTime", "restoreCompany"]);
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
});
});

View File

@ -9,7 +9,6 @@ import type { z } from "zod";
import {
acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema,
renameCompanyRequestSchema,
startUpgradeRequestSchema,
assignPilotRequestSchema,
baySchema,
@ -152,7 +151,6 @@ const tutorial = {
};
const goal = { id: "first-hire", status: "ready" };
const playerState = {
playerId: "player-1",
company,
ships: [ship],
pilots: [pilot],
@ -185,7 +183,6 @@ 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,