Compare commits
9 Commits
621eecb737
...
cab0a1cb81
| Author | SHA1 | Date |
|---|---|---|
|
|
cab0a1cb81 | |
|
|
c15b5ef74e | |
|
|
5030341eb7 | |
|
|
106d44d384 | |
|
|
69cc5ff3a7 | |
|
|
29653e2fdc | |
|
|
531bb77fea | |
|
|
5f202884af | |
|
|
bc29c515eb |
57
DECISIONS.md
57
DECISIONS.md
|
|
@ -1065,3 +1065,60 @@ 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
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
|
|
@ -57,6 +57,13 @@
|
|||
"when": 1791164145228,
|
||||
"tag": "0007_cr010_veteran_pilot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1791171500580,
|
||||
"tag": "0008_cr011_backup_codes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ 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 {
|
||||
|
|
@ -65,7 +66,10 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
|
|||
registerBoardRoutes(app, ctx);
|
||||
registerDispatchRoutes(app, ctx);
|
||||
registerFleetRoutes(app, ctx);
|
||||
if (config.env !== "production") registerTestClockRoute(app, clock);
|
||||
if (config.env !== "production") {
|
||||
registerTestClockRoute(app, clock);
|
||||
registerDevRoutes(app, ctx, clock);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ 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", {
|
||||
|
|
@ -233,3 +236,17 @@ 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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { Company } from "@deadhead/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import { companies } from "../db/schema";
|
||||
|
||||
/** Renames the player's company (decision 0.5.75); the name arrives validated and trimmed. */
|
||||
export async function renameCompany(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
name: string,
|
||||
): Promise<Company> {
|
||||
const [updated] = await tx
|
||||
.update(companies)
|
||||
.set({ name })
|
||||
.where(eq(companies.playerId, playerId))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Player ${playerId} has no company`);
|
||||
return {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
credits: updated.credits,
|
||||
loanBalance: updated.loanBalance,
|
||||
};
|
||||
}
|
||||
|
|
@ -84,3 +84,34 @@ export function pilotName(worldSeed: Seed, n: number): string {
|
|||
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
|
||||
return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`;
|
||||
}
|
||||
|
||||
const COMPANY_FIRST = [
|
||||
"Halcyon",
|
||||
"Meridian",
|
||||
"Lodestar",
|
||||
"Northstar",
|
||||
"Bluewater",
|
||||
"Redline",
|
||||
"Ironclad",
|
||||
"Kestrel",
|
||||
"Copperhead",
|
||||
"Longhaul",
|
||||
"Brightwater",
|
||||
"Stonebridge",
|
||||
];
|
||||
const COMPANY_LAST = [
|
||||
"Freight",
|
||||
"Haulage",
|
||||
"Cargo",
|
||||
"Logistics",
|
||||
"Shipping",
|
||||
"Transit",
|
||||
"Lines",
|
||||
"Carriers",
|
||||
];
|
||||
|
||||
/** A company's default name, stable for its world seed (decision 0.5.75). */
|
||||
export function companyName(worldSeed: Seed): string {
|
||||
const rng = createRng(deriveSeed(worldSeed, "company-name"));
|
||||
return `${rng.pick(COMPANY_FIRST)} ${rng.pick(COMPANY_LAST)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { randomInt, randomUUID } from "node:crypto";
|
||||
import type { RealTime } from "@deadhead/sim";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
acceptedContracts,
|
||||
bays,
|
||||
claimedGoals,
|
||||
companies,
|
||||
contractBoards,
|
||||
discoveredLocations,
|
||||
dispatches,
|
||||
pilots,
|
||||
players,
|
||||
ships,
|
||||
} from "../db/schema";
|
||||
import { requirePlayer } from "./fleet";
|
||||
import { createStartingState } from "./new-player";
|
||||
|
||||
/**
|
||||
* "Start a new company" (decision 0.5.74), inside the caller's player-locked
|
||||
* transaction: deletes everything the company owns, then builds the starting
|
||||
* state exactly as guest creation does, with a new world seed. The player
|
||||
* (and so their sign-ins and backup code) stays.
|
||||
*/
|
||||
export async function newCompany(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
// Children before parents; ship upgrades go with their ships.
|
||||
await tx.delete(claimedGoals).where(eq(claimedGoals.playerId, playerId));
|
||||
await tx.delete(acceptedContracts).where(eq(acceptedContracts.playerId, playerId));
|
||||
await tx.delete(dispatches).where(eq(dispatches.playerId, playerId));
|
||||
await tx.delete(contractBoards).where(eq(contractBoards.playerId, playerId));
|
||||
await tx.delete(discoveredLocations).where(eq(discoveredLocations.playerId, playerId));
|
||||
await tx.delete(bays).where(eq(bays.playerId, playerId));
|
||||
await tx.delete(pilots).where(eq(pilots.playerId, playerId));
|
||||
await tx.delete(ships).where(eq(ships.playerId, playerId));
|
||||
await tx.delete(companies).where(eq(companies.playerId, playerId));
|
||||
|
||||
// A 32-bit seed (decision 0.5.10), never the old one.
|
||||
let worldSeed = randomInt(0, 2 ** 32);
|
||||
while (worldSeed === player.worldSeed) worldSeed = randomInt(0, 2 ** 32);
|
||||
await tx
|
||||
.update(players)
|
||||
.set({
|
||||
worldSeed,
|
||||
tutorialActive: true,
|
||||
loanerLocationId: "earth",
|
||||
tutorialRunsCompleted: 0,
|
||||
dealAccepted: false,
|
||||
})
|
||||
.where(eq(players.id, playerId));
|
||||
await createStartingState(tx, {
|
||||
playerId,
|
||||
companyId: randomUUID(),
|
||||
avatarId: randomUUID(),
|
||||
worldSeed,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import type { RealTime, Seed } from "@deadhead/sim";
|
||||
import type { Db } from "../db/client";
|
||||
import type { Db, Queryable } from "../db/client";
|
||||
import { companies, discoveredLocations, pilots, players } from "../db/schema";
|
||||
import { companyName } from "./names";
|
||||
|
||||
export const AVATAR_NAME = "Captain";
|
||||
export const COMPANY_NAME = "Deadhead Freight";
|
||||
|
||||
export interface NewPlayer {
|
||||
playerId: string;
|
||||
|
|
@ -36,10 +36,26 @@ export async function createPlayer(
|
|||
loanerLocationId: "earth",
|
||||
createdAt: now,
|
||||
});
|
||||
await createStartingState(tx, { playerId, companyId, avatarId, worldSeed, now });
|
||||
});
|
||||
|
||||
return { playerId, companyId, avatarId };
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's starting company (CR-001 tutorial state): starting credits, every
|
||||
* location that starts discovered, and the avatar at Earth beside the loaner.
|
||||
* Shared by guest creation and "Start a new company" (decision 0.5.74).
|
||||
*/
|
||||
export async function createStartingState(
|
||||
tx: Queryable,
|
||||
ids: { playerId: string; companyId: string; avatarId: string; worldSeed: Seed; now: RealTime },
|
||||
): Promise<void> {
|
||||
const { playerId, companyId, avatarId, worldSeed, now } = ids;
|
||||
await tx.insert(companies).values({
|
||||
id: companyId,
|
||||
playerId,
|
||||
name: COMPANY_NAME,
|
||||
name: companyName(worldSeed),
|
||||
credits: content.config.startingCredits,
|
||||
});
|
||||
await tx
|
||||
|
|
@ -57,7 +73,4 @@ export async function createPlayer(
|
|||
locationId: "earth",
|
||||
createdAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
return { playerId, companyId, avatarId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export async function loadPlayerState(
|
|||
);
|
||||
|
||||
return {
|
||||
playerId,
|
||||
company: {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
|
|
|
|||
|
|
@ -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 { players } from "../db/schema";
|
||||
import { playerLogins, players } from "../db/schema";
|
||||
import { ApiError } from "./errors";
|
||||
|
||||
/** The signed-in player's id, or a 401 `unauthorized` error. */
|
||||
|
|
@ -19,6 +19,12 @@ export async function requirePlayerId(
|
|||
.select({ id: players.id })
|
||||
.from(players)
|
||||
.where(eq(players.userId, session.user.id));
|
||||
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
|
||||
return player.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,19 @@ 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";
|
||||
|
|
@ -62,6 +71,63 @@ 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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { companyName } from "../src/game/names";
|
||||
import { useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
const rename = async (name: string) => {
|
||||
const guest = await signUp(testApp().app);
|
||||
const before = (await getState(testApp().app, guest)).company.name;
|
||||
const response = await call(testApp().app, guest, "POST", "/api/company/name", { name });
|
||||
return { guest, before, response };
|
||||
};
|
||||
|
||||
describe("company name (Step 10K.5)", () => {
|
||||
it("starts with a name made from the world seed", async () => {
|
||||
const guest = await signUp(testApp().app);
|
||||
const state = await getState(testApp().app, guest);
|
||||
expect(state.company.name.length).toBeGreaterThanOrEqual(3);
|
||||
expect(companyName(42)).toBe(companyName(42));
|
||||
const names = new Set(Array.from({ length: 30 }, (_, i) => companyName(i)));
|
||||
expect(names.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("saves a valid name, trimmed", async () => {
|
||||
const { guest, response } = await rename(" Rosa & Sons Haulage! ");
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(companyResponseSchema.parse(response.json()).company.name).toBe("Rosa & Sons Haulage!");
|
||||
expect((await getState(testApp().app, guest)).company.name).toBe("Rosa & Sons Haulage!");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["too short", "Ab"],
|
||||
["too long", "A".repeat(33)],
|
||||
["blank after trimming", " "],
|
||||
["disallowed characters", "Freight <script>"],
|
||||
["emoji", "Space 🚀 Co"],
|
||||
])("rejects a name that is %s, changing nothing", async (_why, name) => {
|
||||
const { guest, before, response } = await rename(name);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
|
||||
expect((await getState(testApp().app, guest)).company.name).toBe(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -50,7 +50,7 @@ export function useTestApp(database: () => Database): () => TestApp {
|
|||
return () => current!;
|
||||
}
|
||||
|
||||
function cookieFrom(setCookie: string | string[] | undefined): string {
|
||||
export function cookieFrom(setCookie: string | string[] | undefined): string {
|
||||
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
||||
return values.map((c) => c.split(";")[0]).join("; ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe("database migrations", () => {
|
|||
"discovered_locations",
|
||||
"dispatches",
|
||||
"pilots",
|
||||
"player_logins",
|
||||
"players",
|
||||
"session",
|
||||
"ship_upgrades",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { players } from "../src/db/schema";
|
||||
import { resolveArrivalsJob } from "../src/jobs/definitions";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import {
|
||||
call,
|
||||
getState,
|
||||
insertDispatch,
|
||||
signUp,
|
||||
signUpOwner,
|
||||
testContract,
|
||||
useTestApp,
|
||||
} from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
/** The parts of a state that must match a brand-new guest's (ids differ). */
|
||||
const shape = (s: PlayerState) => ({
|
||||
credits: s.company.credits,
|
||||
loanBalance: s.company.loanBalance,
|
||||
ships: s.ships.length,
|
||||
bays: s.bays.length,
|
||||
pilots: s.pilots.map((p) => ({
|
||||
isAvatar: p.isAvatar,
|
||||
locationId: p.locationId,
|
||||
level: p.level,
|
||||
assignedShipId: p.assignedShipId,
|
||||
})),
|
||||
discovered: s.locations.filter((l) => l.discovered).map((l) => l.id),
|
||||
tutorial: s.tutorial,
|
||||
activeDispatches: s.activeDispatches.length,
|
||||
summaries: s.summaries.length,
|
||||
goals: s.goals,
|
||||
});
|
||||
|
||||
describe("start a new company (Step 10K.6)", () => {
|
||||
it("turns a mid-game company back into a fresh guest start, with a new world seed", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
const owner = await signUpOwner(app, db);
|
||||
await setCredits(db, owner.playerId, 50_000);
|
||||
await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
|
||||
await call(app, owner, "POST", `/api/ships/${owner.shipId}/upgrades`, { upgradeId: "engines" });
|
||||
const flight = await insertDispatch(db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract(),
|
||||
});
|
||||
const [before] = await db.select().from(players).where(eq(players.id, owner.playerId));
|
||||
|
||||
const response = await call(app, owner, "POST", "/api/company/new", {});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const fresh = playerStateSchema.parse(response.json());
|
||||
|
||||
const guest = await signUp(app);
|
||||
expect(shape(fresh)).toEqual(shape(await getState(app, guest)));
|
||||
expect(fresh.tutorial).toMatchObject({ loanerLocationId: "earth", runsCompleted: 0 });
|
||||
expect(fresh.company.credits).toBe(content.config.startingCredits);
|
||||
expect(shape(fresh).discovered.sort()).toEqual(["earth", "moon"]);
|
||||
const [after] = await db.select().from(players).where(eq(players.id, owner.playerId));
|
||||
expect(after!.worldSeed).not.toBe(before!.worldSeed);
|
||||
expect(after!.id).toBe(before!.id);
|
||||
|
||||
// The old flight's arrival job finds nothing and changes nothing.
|
||||
clock.now = flight.arrivalAt + 1000;
|
||||
await expect(
|
||||
resolveArrivalsJob({ db, clock: clock.read }, { playerId: owner.playerId }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(shape(await getState(app, owner))).toEqual(shape(fresh));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@deadhead/web",
|
||||
"version": "0.0.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 {
|
||||
|
|
@ -74,7 +75,11 @@ export function createApiClient(options: ApiClientOptions = {}): ApiClient {
|
|||
const response = await doFetch(baseUrl + url, {
|
||||
method: route.method,
|
||||
credentials: "include",
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
// Restore attempts are rate-limited per device (CR-011).
|
||||
...(name === "restoreCompany" ? { "x-device-id": deviceId() } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
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__;
|
||||
|
|
@ -3,6 +3,7 @@ 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). */
|
||||
|
|
@ -25,6 +26,7 @@ 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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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. */
|
||||
|
|
@ -20,7 +21,28 @@ const FIT = 0.92;
|
|||
|
||||
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
|
||||
|
||||
export const useCamera = create<Camera>(() => DEFAULT_CAMERA);
|
||||
/** 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 });
|
||||
}
|
||||
|
||||
/** Pixels per map unit at this zoom. */
|
||||
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||
import { resetMapView } 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={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||
onClick={resetMapView}
|
||||
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||
>
|
||||
Reset view
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||
import { resetMapView } from "../map/camera";
|
||||
import { About } from "./AboutSettings";
|
||||
import { BackupCompany, RestoreCompany } from "./BackupSettings";
|
||||
import { CompanyName, NewCompany } from "./CompanySettings";
|
||||
import { DevTools } from "./DevTools";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
||||
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
||||
|
|
@ -19,7 +23,12 @@ export function Settings() {
|
|||
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
||||
|
||||
<Section title="Company">
|
||||
<Row label="Company name">{state.company.name}</Row>
|
||||
<CompanyName />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackupCompany />
|
||||
<RestoreCompany pastTutorial={state.tutorial === null} />
|
||||
<NewCompany />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Display">
|
||||
|
|
@ -57,14 +66,7 @@ export function Settings() {
|
|||
|
||||
<Section title="Help">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
useCamera.setState(DEFAULT_CAMERA);
|
||||
prefs.set({ mapCamera: null });
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={resetMapView}>
|
||||
Reset map view
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
|
||||
|
|
@ -74,12 +76,12 @@ export function Settings() {
|
|||
</Section>
|
||||
|
||||
<Section title="About">
|
||||
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
|
||||
<About />
|
||||
</Section>
|
||||
|
||||
{isDevBuild() && (
|
||||
<Section title="Developer">
|
||||
<p className="text-sm text-ink-400">Development build tools.</p>
|
||||
<DevTools />
|
||||
</Section>
|
||||
)}
|
||||
</Screen>
|
||||
|
|
@ -98,15 +100,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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() {
|
||||
|
|
@ -58,6 +59,9 @@ 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.
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ 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();
|
||||
|
|
@ -25,6 +29,9 @@ 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) {
|
||||
|
|
@ -40,7 +47,7 @@ export function TutorialBoard() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{firstRun && (
|
||||
{firstRun && !introDismissed && (
|
||||
<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>
|
||||
|
|
@ -49,6 +56,14 @@ 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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -61,6 +61,7 @@ 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: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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";
|
||||
|
|
@ -23,7 +25,26 @@ 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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ 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.
|
After Width: | Height: | Size: 682 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 608 KiB |
|
|
@ -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` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||
|
||||
### Errors
|
||||
|
||||
|
|
@ -62,6 +62,13 @@ 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 |
|
||||
|
||||
|
|
@ -74,6 +81,7 @@ 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`) |
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ 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
|
||||
|
||||
|
|
@ -338,6 +339,7 @@ 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.).
|
||||
|
|
@ -449,6 +451,7 @@ 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.
|
||||
|
|
@ -462,6 +465,7 @@ 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.
|
||||
|
|
@ -629,6 +633,7 @@ 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
|
||||
|
||||
|
|
@ -652,6 +657,7 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- [ ] 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
|
||||
- [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.**
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ 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),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export const SERVER_ERROR_CODES = [
|
|||
"unknown-goal",
|
||||
"goal-not-ready",
|
||||
"goal-already-claimed",
|
||||
"restore-failed",
|
||||
"rate-limited",
|
||||
] as const;
|
||||
|
||||
export type ErrorCode =
|
||||
|
|
|
|||
|
|
@ -81,3 +81,44 @@ 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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ import {
|
|||
hirePilotRequestSchema,
|
||||
leaseBayRequestSchema,
|
||||
pilotResponseSchema,
|
||||
backupCodeResponseSchema,
|
||||
devAddCreditsRequestSchema,
|
||||
devAdvanceClockRequestSchema,
|
||||
devAdvanceClockResponseSchema,
|
||||
backupCodeStatusSchema,
|
||||
renameCompanyRequestSchema,
|
||||
restoreRequestSchema,
|
||||
repayLoanRequestSchema,
|
||||
shipResponseSchema,
|
||||
spendSkillPointRequestSchema,
|
||||
|
|
@ -151,6 +158,56 @@ 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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ describe("route catalog", () => {
|
|||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
"getBackupCode",
|
||||
"createBackupCode",
|
||||
"restoreCompany",
|
||||
"devAdvanceClock",
|
||||
"devAddCredits",
|
||||
"newCompany",
|
||||
"renameCompany",
|
||||
"repayLoan",
|
||||
"claimGoal",
|
||||
].sort(),
|
||||
|
|
@ -56,8 +63,9 @@ describe("route catalog", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("requires a session everywhere except server time and guest creation", () => {
|
||||
it("requires a session everywhere except server time, guest creation and restore", () => {
|
||||
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
||||
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
|
||||
// Restoring signs a device in, so it runs without a session (CR-011).
|
||||
expect(open.sort()).toEqual(["createGuest", "getServerTime", "restoreCompany"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { z } from "zod";
|
|||
import {
|
||||
acknowledgeSummariesRequestSchema,
|
||||
acknowledgeSummariesResponseSchema,
|
||||
renameCompanyRequestSchema,
|
||||
startUpgradeRequestSchema,
|
||||
assignPilotRequestSchema,
|
||||
baySchema,
|
||||
|
|
@ -151,6 +152,7 @@ const tutorial = {
|
|||
};
|
||||
const goal = { id: "first-hire", status: "ready" };
|
||||
const playerState = {
|
||||
playerId: "player-1",
|
||||
company,
|
||||
ships: [ship],
|
||||
pilots: [pilot],
|
||||
|
|
@ -183,6 +185,7 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
{ ...ship, shop: null },
|
||||
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
|
||||
],
|
||||
["rename company request", renameCompanyRequestSchema, { name: "Kell & Co." }, { name: "Hi" }],
|
||||
[
|
||||
"start upgrade request",
|
||||
startUpgradeRequestSchema,
|
||||
|
|
|
|||
Loading…
Reference in New Issue