Compare commits
No commits in common. "cab0a1cb8133c85e29114f97d6f6f2eda62df117" and "621eecb73789732820635d0740e9ab82be3c8eda" have entirely different histories.
cab0a1cb81
...
621eecb737
57
DECISIONS.md
57
DECISIONS.md
|
|
@ -1065,60 +1065,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **User report:** "On a phone screen the credits and time line can't be read" (screenshot at about 430 px): the credits chip was drawn over the DEADHEAD wordmark. The wordmark showed from 420 px, and 10K.2's gear plus 10K.3's device-locale clock ("2145-07-11 7:18 PM") made the right side wider than the space left.
|
- **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).
|
- **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`).
|
- **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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
CREATE TABLE "player_logins" (
|
|
||||||
"user_id" text PRIMARY KEY NOT NULL,
|
|
||||||
"player_id" text NOT NULL,
|
|
||||||
"created_at" bigint NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "players" ADD COLUMN "backup_code_hash" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "players" ADD COLUMN "backup_code_created_at" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "players" ADD CONSTRAINT "players_backup_code_hash_unique" UNIQUE("backup_code_hash");
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -57,13 +57,6 @@
|
||||||
"when": 1791164145228,
|
"when": 1791164145228,
|
||||||
"tag": "0007_cr010_veteran_pilot",
|
"tag": "0007_cr010_veteran_pilot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1791171500580,
|
|
||||||
"tag": "0008_cr011_backup_codes",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -12,7 +12,6 @@ import type { AppContext } from "./routes/context";
|
||||||
import { registerDispatchRoutes } from "./routes/dispatches";
|
import { registerDispatchRoutes } from "./routes/dispatches";
|
||||||
import { registerFleetRoutes } from "./routes/fleet";
|
import { registerFleetRoutes } from "./routes/fleet";
|
||||||
import { registerPlayerRoutes } from "./routes/player";
|
import { registerPlayerRoutes } from "./routes/player";
|
||||||
import { registerDevRoutes } from "./routes/dev";
|
|
||||||
import { registerTestClockRoute } from "./routes/test-clock";
|
import { registerTestClockRoute } from "./routes/test-clock";
|
||||||
|
|
||||||
export interface AppDeps {
|
export interface AppDeps {
|
||||||
|
|
@ -66,10 +65,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
|
||||||
registerBoardRoutes(app, ctx);
|
registerBoardRoutes(app, ctx);
|
||||||
registerDispatchRoutes(app, ctx);
|
registerDispatchRoutes(app, ctx);
|
||||||
registerFleetRoutes(app, ctx);
|
registerFleetRoutes(app, ctx);
|
||||||
if (config.env !== "production") {
|
if (config.env !== "production") registerTestClockRoute(app, clock);
|
||||||
registerTestClockRoute(app, clock);
|
|
||||||
registerDevRoutes(app, ctx, clock);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,6 @@ export const players = pgTable("players", {
|
||||||
/** The one-time deal has been accepted (decision 0.5.30). */
|
/** The one-time deal has been accepted (decision 0.5.30). */
|
||||||
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
||||||
createdAt: time("created_at").notNull(),
|
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", {
|
export const companies = pgTable("companies", {
|
||||||
|
|
@ -236,17 +233,3 @@ export const claimedGoals = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
|
(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(),
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { createHash, randomInt } from "node:crypto";
|
|
||||||
import type { RealTime } from "@deadhead/sim";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Queryable } from "../db/client";
|
|
||||||
import { players } from "../db/schema";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup codes (decision 0.5.72): 20 characters from 31 that can't be mistaken
|
|
||||||
* for each other (no 0/O, 1/I/L), shown as four groups of five: about 99 bits.
|
|
||||||
* Only a SHA-256 of the normalized code is stored; the code itself is random
|
|
||||||
* enough that a slow hash would add nothing.
|
|
||||||
*/
|
|
||||||
export const BACKUP_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
||||||
const GROUPS = 4;
|
|
||||||
const GROUP_LENGTH = 5;
|
|
||||||
|
|
||||||
export function generateBackupCode(): string {
|
|
||||||
const groups = Array.from({ length: GROUPS }, () =>
|
|
||||||
Array.from(
|
|
||||||
{ length: GROUP_LENGTH },
|
|
||||||
() => BACKUP_ALPHABET[randomInt(BACKUP_ALPHABET.length)],
|
|
||||||
).join(""),
|
|
||||||
);
|
|
||||||
return groups.join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The code without spaces or dashes, in capitals: how players type it doesn't matter. */
|
|
||||||
export function normalizeBackupCode(code: string): string {
|
|
||||||
return code.toUpperCase().replace(/[\s-]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashBackupCode(code: string): string {
|
|
||||||
return createHash("sha256").update(normalizeBackupCode(code)).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Makes a new code for the player, replacing any old one; returns it once. */
|
|
||||||
export async function createBackupCode(
|
|
||||||
tx: Queryable,
|
|
||||||
playerId: string,
|
|
||||||
now: RealTime,
|
|
||||||
): Promise<{ code: string; createdAt: RealTime }> {
|
|
||||||
const code = generateBackupCode();
|
|
||||||
await tx
|
|
||||||
.update(players)
|
|
||||||
.set({ backupCodeHash: hashBackupCode(code), backupCodeCreatedAt: now })
|
|
||||||
.where(eq(players.id, playerId));
|
|
||||||
return { code, createdAt: now };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function backupCodeStatus(tx: Queryable, playerId: string) {
|
|
||||||
const [row] = await tx
|
|
||||||
.select({ hash: players.backupCodeHash, createdAt: players.backupCodeCreatedAt })
|
|
||||||
.from(players)
|
|
||||||
.where(eq(players.id, playerId));
|
|
||||||
return { hasCode: Boolean(row?.hash), createdAt: row?.hash ? (row.createdAt ?? null) : null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The player a backup code belongs to, or null. */
|
|
||||||
export async function playerForBackupCode(tx: Queryable, code: string): Promise<string | null> {
|
|
||||||
if (normalizeBackupCode(code).length === 0) return null;
|
|
||||||
const [row] = await tx
|
|
||||||
.select({ id: players.id })
|
|
||||||
.from(players)
|
|
||||||
.where(eq(players.backupCodeHash, hashBackupCode(code)));
|
|
||||||
return row?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Failed restore attempts per key (an IP, a device), in memory: at most
|
|
||||||
* `limit` failures per `windowMs` for any key (decision 0.5.72).
|
|
||||||
*/
|
|
||||||
export class RestoreLimiter {
|
|
||||||
private readonly failures = new Map<string, { count: number; resetAt: RealTime }>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly limit = 5,
|
|
||||||
readonly windowMs = 15 * 60_000,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Whether any of the keys has used up its attempts. */
|
|
||||||
blocked(keys: readonly string[], now: RealTime): boolean {
|
|
||||||
return keys.some((key) => {
|
|
||||||
const entry = this.failures.get(key);
|
|
||||||
return entry !== undefined && entry.resetAt > now && entry.count >= this.limit;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fail(keys: readonly string[], now: RealTime): void {
|
|
||||||
for (const key of keys) {
|
|
||||||
const entry = this.failures.get(key);
|
|
||||||
if (!entry || entry.resetAt <= now)
|
|
||||||
this.failures.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
||||||
else entry.count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import type { Company } from "@deadhead/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Queryable } from "../db/client";
|
|
||||||
import { companies } from "../db/schema";
|
|
||||||
|
|
||||||
/** Renames the player's company (decision 0.5.75); the name arrives validated and trimmed. */
|
|
||||||
export async function renameCompany(
|
|
||||||
tx: Queryable,
|
|
||||||
playerId: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<Company> {
|
|
||||||
const [updated] = await tx
|
|
||||||
.update(companies)
|
|
||||||
.set({ name })
|
|
||||||
.where(eq(companies.playerId, playerId))
|
|
||||||
.returning();
|
|
||||||
if (!updated) throw new Error(`Player ${playerId} has no company`);
|
|
||||||
return {
|
|
||||||
id: updated.id,
|
|
||||||
name: updated.name,
|
|
||||||
credits: updated.credits,
|
|
||||||
loanBalance: updated.loanBalance,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -84,34 +84,3 @@ export function pilotName(worldSeed: Seed, n: number): string {
|
||||||
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
|
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
|
||||||
return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`;
|
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)}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { randomInt, randomUUID } from "node:crypto";
|
|
||||||
import type { RealTime } from "@deadhead/sim";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Queryable } from "../db/client";
|
|
||||||
import {
|
|
||||||
acceptedContracts,
|
|
||||||
bays,
|
|
||||||
claimedGoals,
|
|
||||||
companies,
|
|
||||||
contractBoards,
|
|
||||||
discoveredLocations,
|
|
||||||
dispatches,
|
|
||||||
pilots,
|
|
||||||
players,
|
|
||||||
ships,
|
|
||||||
} from "../db/schema";
|
|
||||||
import { requirePlayer } from "./fleet";
|
|
||||||
import { createStartingState } from "./new-player";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Start a new company" (decision 0.5.74), inside the caller's player-locked
|
|
||||||
* transaction: deletes everything the company owns, then builds the starting
|
|
||||||
* state exactly as guest creation does, with a new world seed. The player
|
|
||||||
* (and so their sign-ins and backup code) stays.
|
|
||||||
*/
|
|
||||||
export async function newCompany(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
|
|
||||||
const player = await requirePlayer(tx, playerId);
|
|
||||||
// Children before parents; ship upgrades go with their ships.
|
|
||||||
await tx.delete(claimedGoals).where(eq(claimedGoals.playerId, playerId));
|
|
||||||
await tx.delete(acceptedContracts).where(eq(acceptedContracts.playerId, playerId));
|
|
||||||
await tx.delete(dispatches).where(eq(dispatches.playerId, playerId));
|
|
||||||
await tx.delete(contractBoards).where(eq(contractBoards.playerId, playerId));
|
|
||||||
await tx.delete(discoveredLocations).where(eq(discoveredLocations.playerId, playerId));
|
|
||||||
await tx.delete(bays).where(eq(bays.playerId, playerId));
|
|
||||||
await tx.delete(pilots).where(eq(pilots.playerId, playerId));
|
|
||||||
await tx.delete(ships).where(eq(ships.playerId, playerId));
|
|
||||||
await tx.delete(companies).where(eq(companies.playerId, playerId));
|
|
||||||
|
|
||||||
// A 32-bit seed (decision 0.5.10), never the old one.
|
|
||||||
let worldSeed = randomInt(0, 2 ** 32);
|
|
||||||
while (worldSeed === player.worldSeed) worldSeed = randomInt(0, 2 ** 32);
|
|
||||||
await tx
|
|
||||||
.update(players)
|
|
||||||
.set({
|
|
||||||
worldSeed,
|
|
||||||
tutorialActive: true,
|
|
||||||
loanerLocationId: "earth",
|
|
||||||
tutorialRunsCompleted: 0,
|
|
||||||
dealAccepted: false,
|
|
||||||
})
|
|
||||||
.where(eq(players.id, playerId));
|
|
||||||
await createStartingState(tx, {
|
|
||||||
playerId,
|
|
||||||
companyId: randomUUID(),
|
|
||||||
avatarId: randomUUID(),
|
|
||||||
worldSeed,
|
|
||||||
now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { RealTime, Seed } from "@deadhead/sim";
|
import type { RealTime, Seed } from "@deadhead/sim";
|
||||||
import type { Db, Queryable } from "../db/client";
|
import type { Db } from "../db/client";
|
||||||
import { companies, discoveredLocations, pilots, players } from "../db/schema";
|
import { companies, discoveredLocations, pilots, players } from "../db/schema";
|
||||||
import { companyName } from "./names";
|
|
||||||
|
|
||||||
export const AVATAR_NAME = "Captain";
|
export const AVATAR_NAME = "Captain";
|
||||||
|
export const COMPANY_NAME = "Deadhead Freight";
|
||||||
|
|
||||||
export interface NewPlayer {
|
export interface NewPlayer {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
|
|
@ -36,26 +36,10 @@ export async function createPlayer(
|
||||||
loanerLocationId: "earth",
|
loanerLocationId: "earth",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
await createStartingState(tx, { playerId, companyId, avatarId, worldSeed, now });
|
|
||||||
});
|
|
||||||
|
|
||||||
return { playerId, companyId, avatarId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A player's starting company (CR-001 tutorial state): starting credits, every
|
|
||||||
* location that starts discovered, and the avatar at Earth beside the loaner.
|
|
||||||
* Shared by guest creation and "Start a new company" (decision 0.5.74).
|
|
||||||
*/
|
|
||||||
export async function createStartingState(
|
|
||||||
tx: Queryable,
|
|
||||||
ids: { playerId: string; companyId: string; avatarId: string; worldSeed: Seed; now: RealTime },
|
|
||||||
): Promise<void> {
|
|
||||||
const { playerId, companyId, avatarId, worldSeed, now } = ids;
|
|
||||||
await tx.insert(companies).values({
|
await tx.insert(companies).values({
|
||||||
id: companyId,
|
id: companyId,
|
||||||
playerId,
|
playerId,
|
||||||
name: companyName(worldSeed),
|
name: COMPANY_NAME,
|
||||||
credits: content.config.startingCredits,
|
credits: content.config.startingCredits,
|
||||||
});
|
});
|
||||||
await tx
|
await tx
|
||||||
|
|
@ -73,4 +57,7 @@ export async function createStartingState(
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { playerId, companyId, avatarId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ export async function loadPlayerState(
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerId,
|
|
||||||
company: {
|
company: {
|
||||||
id: company.id,
|
id: company.id,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { fromNodeHeaders } from "better-auth/node";
|
||||||
import type { FastifyRequest } from "fastify";
|
import type { FastifyRequest } from "fastify";
|
||||||
import type { Auth } from "../auth";
|
import type { Auth } from "../auth";
|
||||||
import type { Db } from "../db/client";
|
import type { Db } from "../db/client";
|
||||||
import { playerLogins, players } from "../db/schema";
|
import { players } from "../db/schema";
|
||||||
import { ApiError } from "./errors";
|
import { ApiError } from "./errors";
|
||||||
|
|
||||||
/** The signed-in player's id, or a 401 `unauthorized` error. */
|
/** The signed-in player's id, or a 401 `unauthorized` error. */
|
||||||
|
|
@ -19,12 +19,6 @@ export async function requirePlayerId(
|
||||||
.select({ id: players.id })
|
.select({ id: players.id })
|
||||||
.from(players)
|
.from(players)
|
||||||
.where(eq(players.userId, session.user.id));
|
.where(eq(players.userId, session.user.id));
|
||||||
if (player) return player.id;
|
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
|
||||||
// A device signed in with a backup code (decision 0.5.72).
|
return player.id;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { routes } from "@deadhead/schema";
|
|
||||||
import { eq, sql } from "drizzle-orm";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
|
||||||
import type { OffsetClock } from "../clock";
|
|
||||||
import { companies } from "../db/schema";
|
|
||||||
import { registerRoute } from "../http/route";
|
|
||||||
import type { AppContext } from "./context";
|
|
||||||
import { withPlayer } from "./with-player";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Developer tools (CR-011): fast-forward the clock and add credits. Only
|
|
||||||
* registered outside production, like the clock-offset route (decision 0.5.18).
|
|
||||||
*/
|
|
||||||
export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void {
|
|
||||||
registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => {
|
|
||||||
clock.setOffset(clock.offsetMs() + input.advanceMs);
|
|
||||||
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
|
|
||||||
});
|
|
||||||
|
|
||||||
registerRoute(app, routes.devAddCredits, ctx.options, async (input, { playerId }) =>
|
|
||||||
withPlayer(ctx, playerId, async (tx) => {
|
|
||||||
const [company] = await tx
|
|
||||||
.update(companies)
|
|
||||||
.set({ credits: sql`${companies.credits} + ${input.amount}` })
|
|
||||||
.where(eq(companies.playerId, playerId))
|
|
||||||
.returning();
|
|
||||||
return {
|
|
||||||
company: {
|
|
||||||
id: company!.id,
|
|
||||||
name: company!.name,
|
|
||||||
credits: company!.credits,
|
|
||||||
loanBalance: company!.loanBalance,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,19 +6,10 @@ import { fromNodeHeaders } from "better-auth/node";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { acceptDeal } from "../game/deal";
|
import { acceptDeal } from "../game/deal";
|
||||||
import { claimGoal } from "../game/goals";
|
import { claimGoal } from "../game/goals";
|
||||||
import {
|
|
||||||
backupCodeStatus,
|
|
||||||
createBackupCode,
|
|
||||||
playerForBackupCode,
|
|
||||||
RestoreLimiter,
|
|
||||||
} from "../game/backup";
|
|
||||||
import { renameCompany } from "../game/company";
|
|
||||||
import { repayLoan } from "../game/loan";
|
import { repayLoan } from "../game/loan";
|
||||||
import { newCompany } from "../game/new-company";
|
|
||||||
import { createPlayer } from "../game/new-player";
|
import { createPlayer } from "../game/new-player";
|
||||||
import { loadPlayerState } from "../game/player-state";
|
import { loadPlayerState } from "../game/player-state";
|
||||||
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
||||||
import { playerLogins } from "../db/schema";
|
|
||||||
import { ApiError } from "../http/errors";
|
import { ApiError } from "../http/errors";
|
||||||
import { registerRoute } from "../http/route";
|
import { registerRoute } from "../http/route";
|
||||||
import type { AppContext } from "./context";
|
import type { AppContext } from "./context";
|
||||||
|
|
@ -71,63 +62,6 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
registerRoute(app, routes.getBackupCode, options, async (_input, { playerId }) =>
|
|
||||||
withPlayer(ctx, playerId, (tx) => backupCodeStatus(tx, playerId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
registerRoute(app, routes.createBackupCode, options, async (_input, { playerId }) =>
|
|
||||||
withPlayer(ctx, playerId, (tx, now) => createBackupCode(tx, playerId, now)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restoring signs this device in as the code's player (decisions 0.5.72, 0.5.73).
|
|
||||||
const limiter = new RestoreLimiter();
|
|
||||||
registerRoute(app, routes.restoreCompany, options, async (input, { request, reply }) => {
|
|
||||||
const now = clock();
|
|
||||||
const device = request.headers["x-device-id"];
|
|
||||||
const keys = [`ip:${request.ip}`, ...(typeof device === "string" ? [`device:${device}`] : [])];
|
|
||||||
if (limiter.blocked(keys, now)) {
|
|
||||||
throw new ApiError(
|
|
||||||
429,
|
|
||||||
"rate-limited",
|
|
||||||
"Too many attempts. Wait a few minutes and try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const playerId = await playerForBackupCode(db, input.code);
|
|
||||||
if (!playerId) {
|
|
||||||
limiter.fail(keys, now);
|
|
||||||
throw new ApiError(
|
|
||||||
409,
|
|
||||||
"restore-failed",
|
|
||||||
"That code didn't open a company. Check it and try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// A fresh guest sign-in for this device (any old session is left behind),
|
|
||||||
// linked to the code's player.
|
|
||||||
const headers = fromNodeHeaders(request.headers);
|
|
||||||
headers.delete("cookie");
|
|
||||||
const signIn = await auth.api.signInAnonymous({ headers, returnHeaders: true });
|
|
||||||
if (!signIn.response)
|
|
||||||
throw new ApiError(500, "internal-error", "Could not sign this device in.");
|
|
||||||
await db
|
|
||||||
.insert(playerLogins)
|
|
||||||
.values({ userId: signIn.response.user.id, playerId, createdAt: now });
|
|
||||||
reply.header("set-cookie", signIn.headers.getSetCookie());
|
|
||||||
return { playerId, state: await loadPlayerState(db, playerId, now) };
|
|
||||||
});
|
|
||||||
|
|
||||||
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
|
|
||||||
withPlayer(ctx, playerId, async (tx, now) => {
|
|
||||||
await newCompany(tx, playerId, now);
|
|
||||||
return loadPlayerState(tx, playerId, now);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
registerRoute(app, routes.renameCompany, options, async (input, { playerId }) =>
|
|
||||||
withPlayer(ctx, playerId, async (tx) => ({
|
|
||||||
company: await renameCompany(tx, playerId, input.name),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
|
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx) => ({
|
withPlayer(ctx, playerId, async (tx) => ({
|
||||||
company: await repayLoan(tx, playerId, input.amount),
|
company: await repayLoan(tx, playerId, input.amount),
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import {
|
|
||||||
backupCodeResponseSchema,
|
|
||||||
createGuestResponseSchema,
|
|
||||||
errorResponseSchema,
|
|
||||||
} from "@deadhead/schema";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { BACKUP_ALPHABET, normalizeBackupCode } from "../src/game/backup";
|
|
||||||
import { setCredits, useTestDatabase } from "./db";
|
|
||||||
import { call, cookieFrom, getState, signUp, signUpOwner, useTestApp, type Guest } from "./game";
|
|
||||||
|
|
||||||
const database = useTestDatabase();
|
|
||||||
const testApp = useTestApp(database);
|
|
||||||
|
|
||||||
const makeCode = async (guest: Guest) =>
|
|
||||||
backupCodeResponseSchema.parse(
|
|
||||||
(await call(testApp().app, guest, "POST", "/api/backup-code", {})).json(),
|
|
||||||
).code;
|
|
||||||
|
|
||||||
/** Restores on a new device (no cookie); `device` sets the device-id header. */
|
|
||||||
const restore = (code: string, device = "device-a", ip = "10.0.0.1") =>
|
|
||||||
testApp().app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/restore",
|
|
||||||
headers: { "x-device-id": device },
|
|
||||||
remoteAddress: ip,
|
|
||||||
payload: { code },
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backup codes (Step 10K.8)", () => {
|
|
||||||
it("makes a 20-character code in four groups, without look-alike characters", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const code = await makeCode(guest);
|
|
||||||
expect(code).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}$/);
|
|
||||||
for (const ch of normalizeBackupCode(code)) expect(BACKUP_ALPHABET).toContain(ch);
|
|
||||||
expect(code).not.toMatch(/[01OIL]/);
|
|
||||||
expect((await call(testApp().app, guest, "GET", "/api/backup-code")).json()).toMatchObject({
|
|
||||||
hasCode: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores the same player on a second device, and both see the same state", async () => {
|
|
||||||
const { app } = testApp();
|
|
||||||
const owner = await signUpOwner(app, database().db);
|
|
||||||
await setCredits(database().db, owner.playerId, 4321);
|
|
||||||
const code = await makeCode(owner);
|
|
||||||
|
|
||||||
const response = await restore(code.toLowerCase().replaceAll("-", " "));
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = createGuestResponseSchema.parse(response.json());
|
|
||||||
expect(body.playerId).toBe(owner.playerId);
|
|
||||||
const second: Guest = { ...owner, cookie: cookieFrom(response.headers["set-cookie"]) };
|
|
||||||
expect(second.cookie).not.toBe(owner.cookie);
|
|
||||||
|
|
||||||
await call(app, second, "POST", "/api/company/name", { name: "Second Device Co" });
|
|
||||||
expect((await getState(app, owner)).company).toMatchObject({
|
|
||||||
name: "Second Device Co",
|
|
||||||
credits: 4321,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops the old code working when a new one is made", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const first = await makeCode(guest);
|
|
||||||
const second = await makeCode(guest);
|
|
||||||
expect(second).not.toBe(first);
|
|
||||||
expect((await restore(first)).statusCode).toBe(409);
|
|
||||||
expect((await restore(second)).statusCode).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails a wrong code with a generic error, and limits attempts", async () => {
|
|
||||||
const wrong = await restore("ABCDE-FGHJK-MNPQR-STUVW", "device-b", "10.0.0.2");
|
|
||||||
expect(wrong.statusCode).toBe(409);
|
|
||||||
expect(errorResponseSchema.parse(wrong.json()).error.code).toBe("restore-failed");
|
|
||||||
for (let i = 0; i < 4; i++) await restore("NOPE", "device-b", "10.0.0.2");
|
|
||||||
const limited = await restore("NOPE", "device-b", "10.0.0.2");
|
|
||||||
expect(limited.statusCode).toBe(429);
|
|
||||||
expect(errorResponseSchema.parse(limited.json()).error.code).toBe("rate-limited");
|
|
||||||
// The same device from another network is still limited.
|
|
||||||
expect((await restore("NOPE", "device-b", "10.0.0.3")).statusCode).toBe(429);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never stores the plain code", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const code = await makeCode(guest);
|
|
||||||
const plain = normalizeBackupCode(code);
|
|
||||||
const rows = await database().db.execute(
|
|
||||||
sql`select row_to_json(p)::text as row from players p`,
|
|
||||||
);
|
|
||||||
for (const { row } of rows.rows as { row: string }[]) {
|
|
||||||
expect(row).not.toContain(code);
|
|
||||||
expect(row).not.toContain(plain);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still restores the player after they start a new company", async () => {
|
|
||||||
const owner = await signUpOwner(testApp().app, database().db);
|
|
||||||
const code = await makeCode(owner);
|
|
||||||
await call(testApp().app, owner, "POST", "/api/company/new", {});
|
|
||||||
const response = await restore(code, "device-c", "10.0.0.4");
|
|
||||||
expect(createGuestResponseSchema.parse(response.json())).toMatchObject({
|
|
||||||
playerId: owner.playerId,
|
|
||||||
state: { tutorial: { runsCompleted: 0 } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { companyName } from "../src/game/names";
|
|
||||||
import { useTestDatabase } from "./db";
|
|
||||||
import { call, getState, signUp, useTestApp } from "./game";
|
|
||||||
|
|
||||||
const database = useTestDatabase();
|
|
||||||
const testApp = useTestApp(database);
|
|
||||||
|
|
||||||
const rename = async (name: string) => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const before = (await getState(testApp().app, guest)).company.name;
|
|
||||||
const response = await call(testApp().app, guest, "POST", "/api/company/name", { name });
|
|
||||||
return { guest, before, response };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("company name (Step 10K.5)", () => {
|
|
||||||
it("starts with a name made from the world seed", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const state = await getState(testApp().app, guest);
|
|
||||||
expect(state.company.name.length).toBeGreaterThanOrEqual(3);
|
|
||||||
expect(companyName(42)).toBe(companyName(42));
|
|
||||||
const names = new Set(Array.from({ length: 30 }, (_, i) => companyName(i)));
|
|
||||||
expect(names.size).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves a valid name, trimmed", async () => {
|
|
||||||
const { guest, response } = await rename(" Rosa & Sons Haulage! ");
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(companyResponseSchema.parse(response.json()).company.name).toBe("Rosa & Sons Haulage!");
|
|
||||||
expect((await getState(testApp().app, guest)).company.name).toBe("Rosa & Sons Haulage!");
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["too short", "Ab"],
|
|
||||||
["too long", "A".repeat(33)],
|
|
||||||
["blank after trimming", " "],
|
|
||||||
["disallowed characters", "Freight <script>"],
|
|
||||||
["emoji", "Space 🚀 Co"],
|
|
||||||
])("rejects a name that is %s, changing nothing", async (_why, name) => {
|
|
||||||
const { guest, before, response } = await rename(name);
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
|
|
||||||
expect((await getState(testApp().app, guest)).company.name).toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { companyResponseSchema } from "@deadhead/schema";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildApp } from "../src/app";
|
|
||||||
import { createRecordingQueue } from "../src/jobs/queue";
|
|
||||||
import { useTestDatabase } from "./db";
|
|
||||||
import {
|
|
||||||
call,
|
|
||||||
getState,
|
|
||||||
insertDispatch,
|
|
||||||
signUp,
|
|
||||||
signUpOwner,
|
|
||||||
testContract,
|
|
||||||
useTestApp,
|
|
||||||
} from "./game";
|
|
||||||
import { testConfig } from "./helpers";
|
|
||||||
|
|
||||||
const database = useTestDatabase();
|
|
||||||
const testApp = useTestApp(database);
|
|
||||||
|
|
||||||
describe("developer tools (Step 10K.11)", () => {
|
|
||||||
it("advances the server clock", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const before = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
|
|
||||||
const response = await call(testApp().app, guest, "POST", "/api/test/advance-clock", {
|
|
||||||
advanceMs: 600_000,
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const after = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
|
|
||||||
expect(after - before).toBeGreaterThanOrEqual(600_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds credits", async () => {
|
|
||||||
const guest = await signUp(testApp().app);
|
|
||||||
const start = (await getState(testApp().app, guest)).company.credits;
|
|
||||||
const response = await call(testApp().app, guest, "POST", "/api/test/add-credits", {
|
|
||||||
amount: 10_000,
|
|
||||||
});
|
|
||||||
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(start + 10_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lands every flight when advanced to the last arrival", async () => {
|
|
||||||
const { app, clock } = testApp();
|
|
||||||
const owner = await signUpOwner(app, database().db);
|
|
||||||
const flight = await insertDispatch(database().db, {
|
|
||||||
playerId: owner.playerId,
|
|
||||||
shipId: owner.shipId,
|
|
||||||
pilotId: owner.avatarId,
|
|
||||||
departAt: clock.now,
|
|
||||||
contract: testContract(),
|
|
||||||
});
|
|
||||||
expect((await getState(app, owner)).activeDispatches).toHaveLength(1);
|
|
||||||
await call(app, owner, "POST", "/api/test/advance-clock", {
|
|
||||||
advanceMs: flight.arrivalAt - clock.now + 1000,
|
|
||||||
});
|
|
||||||
expect((await getState(app, owner)).activeDispatches).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not exist in production", async () => {
|
|
||||||
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
|
|
||||||
database: database(),
|
|
||||||
jobs: createRecordingQueue(),
|
|
||||||
});
|
|
||||||
for (const [url, payload] of [
|
|
||||||
["/api/test/clock-offset", { offsetMs: 1 }],
|
|
||||||
["/api/test/advance-clock", { advanceMs: 1000 }],
|
|
||||||
["/api/test/add-credits", { amount: 10_000 }],
|
|
||||||
] as const) {
|
|
||||||
const response = await app.inject({ method: "POST", url, payload });
|
|
||||||
expect(response.statusCode, url).toBe(404);
|
|
||||||
}
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function useTestApp(database: () => Database): () => TestApp {
|
||||||
return () => current!;
|
return () => current!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cookieFrom(setCookie: string | string[] | undefined): string {
|
function cookieFrom(setCookie: string | string[] | undefined): string {
|
||||||
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
||||||
return values.map((c) => c.split(";")[0]).join("; ");
|
return values.map((c) => c.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ describe("database migrations", () => {
|
||||||
"discovered_locations",
|
"discovered_locations",
|
||||||
"dispatches",
|
"dispatches",
|
||||||
"pilots",
|
"pilots",
|
||||||
"player_logins",
|
|
||||||
"players",
|
"players",
|
||||||
"session",
|
"session",
|
||||||
"ship_upgrades",
|
"ship_upgrades",
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { content } from "@deadhead/content";
|
|
||||||
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { players } from "../src/db/schema";
|
|
||||||
import { resolveArrivalsJob } from "../src/jobs/definitions";
|
|
||||||
import { setCredits, useTestDatabase } from "./db";
|
|
||||||
import {
|
|
||||||
call,
|
|
||||||
getState,
|
|
||||||
insertDispatch,
|
|
||||||
signUp,
|
|
||||||
signUpOwner,
|
|
||||||
testContract,
|
|
||||||
useTestApp,
|
|
||||||
} from "./game";
|
|
||||||
|
|
||||||
const database = useTestDatabase();
|
|
||||||
const testApp = useTestApp(database);
|
|
||||||
|
|
||||||
/** The parts of a state that must match a brand-new guest's (ids differ). */
|
|
||||||
const shape = (s: PlayerState) => ({
|
|
||||||
credits: s.company.credits,
|
|
||||||
loanBalance: s.company.loanBalance,
|
|
||||||
ships: s.ships.length,
|
|
||||||
bays: s.bays.length,
|
|
||||||
pilots: s.pilots.map((p) => ({
|
|
||||||
isAvatar: p.isAvatar,
|
|
||||||
locationId: p.locationId,
|
|
||||||
level: p.level,
|
|
||||||
assignedShipId: p.assignedShipId,
|
|
||||||
})),
|
|
||||||
discovered: s.locations.filter((l) => l.discovered).map((l) => l.id),
|
|
||||||
tutorial: s.tutorial,
|
|
||||||
activeDispatches: s.activeDispatches.length,
|
|
||||||
summaries: s.summaries.length,
|
|
||||||
goals: s.goals,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("start a new company (Step 10K.6)", () => {
|
|
||||||
it("turns a mid-game company back into a fresh guest start, with a new world seed", async () => {
|
|
||||||
const { app, clock } = testApp();
|
|
||||||
const { db } = database();
|
|
||||||
const owner = await signUpOwner(app, db);
|
|
||||||
await setCredits(db, owner.playerId, 50_000);
|
|
||||||
await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
|
|
||||||
await call(app, owner, "POST", `/api/ships/${owner.shipId}/upgrades`, { upgradeId: "engines" });
|
|
||||||
const flight = await insertDispatch(db, {
|
|
||||||
playerId: owner.playerId,
|
|
||||||
shipId: owner.shipId,
|
|
||||||
pilotId: owner.avatarId,
|
|
||||||
departAt: clock.now,
|
|
||||||
contract: testContract(),
|
|
||||||
});
|
|
||||||
const [before] = await db.select().from(players).where(eq(players.id, owner.playerId));
|
|
||||||
|
|
||||||
const response = await call(app, owner, "POST", "/api/company/new", {});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const fresh = playerStateSchema.parse(response.json());
|
|
||||||
|
|
||||||
const guest = await signUp(app);
|
|
||||||
expect(shape(fresh)).toEqual(shape(await getState(app, guest)));
|
|
||||||
expect(fresh.tutorial).toMatchObject({ loanerLocationId: "earth", runsCompleted: 0 });
|
|
||||||
expect(fresh.company.credits).toBe(content.config.startingCredits);
|
|
||||||
expect(shape(fresh).discovered.sort()).toEqual(["earth", "moon"]);
|
|
||||||
const [after] = await db.select().from(players).where(eq(players.id, owner.playerId));
|
|
||||||
expect(after!.worldSeed).not.toBe(before!.worldSeed);
|
|
||||||
expect(after!.id).toBe(before!.id);
|
|
||||||
|
|
||||||
// The old flight's arrival job finds nothing and changes nothing.
|
|
||||||
clock.now = flight.arrivalAt + 1000;
|
|
||||||
await expect(
|
|
||||||
resolveArrivalsJob({ db, clock: clock.read }, { playerId: owner.playerId }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
expect(shape(await getState(app, owner))).toEqual(shape(fresh));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@deadhead/web",
|
"name": "@deadhead/web",
|
||||||
"version": "0.10.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
type RouteRequest,
|
type RouteRequest,
|
||||||
type RouteResponse,
|
type RouteResponse,
|
||||||
} from "@deadhead/schema";
|
} from "@deadhead/schema";
|
||||||
import { deviceId } from "./device";
|
|
||||||
|
|
||||||
/** The server answered with its documented error format. */
|
/** The server answered with its documented error format. */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|
@ -75,11 +74,7 @@ export function createApiClient(options: ApiClientOptions = {}): ApiClient {
|
||||||
const response = await doFetch(baseUrl + url, {
|
const response = await doFetch(baseUrl + url, {
|
||||||
method: route.method,
|
method: route.method,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: body ? { "content-type": "application/json" } : {},
|
||||||
...(body ? { "content-type": "application/json" } : {}),
|
|
||||||
// Restore attempts are rate-limited per device (CR-011).
|
|
||||||
...(name === "restoreCompany" ? { "x-device-id": deviceId() } : {}),
|
|
||||||
},
|
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
const DEVICE_KEY = "deadhead:device-id";
|
|
||||||
let fallback: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A random id for this device, kept in local storage: the server rate-limits
|
|
||||||
* restore attempts per device (decision 0.5.72). Without storage it lasts for
|
|
||||||
* the page's lifetime.
|
|
||||||
*/
|
|
||||||
export function deviceId(): string {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(DEVICE_KEY);
|
|
||||||
if (saved) return saved;
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
localStorage.setItem(DEVICE_KEY, id);
|
|
||||||
return id;
|
|
||||||
} catch {
|
|
||||||
fallback ??= Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
declare const __APP_VERSION__: string;
|
|
||||||
declare const __APP_BUILD__: string;
|
|
||||||
|
|
||||||
/** The game's version and build (git commit), baked in by Vite (CR-011). */
|
|
||||||
export const APP_VERSION: string = __APP_VERSION__;
|
|
||||||
export const APP_BUILD: string = __APP_BUILD__;
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Link, NavLink, Outlet } from "react-router";
|
||||||
import { GameClock } from "../clock/GameClock";
|
import { GameClock } from "../clock/GameClock";
|
||||||
import { SCREENS, type ScreenPath } from "../routes";
|
import { SCREENS, type ScreenPath } from "../routes";
|
||||||
import { Icon } from "../ui";
|
import { Icon } from "../ui";
|
||||||
import { useTrackScreen } from "./lastScreen";
|
|
||||||
|
|
||||||
export interface LayoutProps {
|
export interface LayoutProps {
|
||||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||||
|
|
@ -26,7 +25,6 @@ export function Layout({
|
||||||
banner,
|
banner,
|
||||||
attention = {},
|
attention = {},
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
useTrackScreen();
|
|
||||||
return (
|
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]">
|
<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">
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useLocation } from "react-router";
|
|
||||||
|
|
||||||
let lastScreen = "/";
|
|
||||||
|
|
||||||
/** Records the screen the player is on, ignoring Settings itself (for debug info, CR-011). */
|
|
||||||
export function useTrackScreen(): void {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
if (pathname !== "/settings") lastScreen = pathname;
|
|
||||||
}, [pathname]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last screen before Settings, e.g. "/fleet". */
|
|
||||||
export function lastScreenPath(): string {
|
|
||||||
return lastScreen;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { usePreferences } from "../state/preferences";
|
|
||||||
import type { MapPoint } from "./projection";
|
import type { MapPoint } from "./projection";
|
||||||
|
|
||||||
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
|
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
|
||||||
|
|
@ -21,28 +20,7 @@ const FIT = 0.92;
|
||||||
|
|
||||||
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
|
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
|
||||||
|
|
||||||
/** The map view, starting where the player left it on this device (CR-011). */
|
export const useCamera = create<Camera>(() => DEFAULT_CAMERA);
|
||||||
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. */
|
/** Pixels per map unit at this zoom. */
|
||||||
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { APP_BUILD, APP_VERSION } from "../build-info";
|
|
||||||
import { serverNow, useClock } from "../clock/clock";
|
|
||||||
import { lastScreenPath } from "../layout/lastScreen";
|
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
|
||||||
import { Button } from "../ui";
|
|
||||||
|
|
||||||
/** The text "Copy debug info" puts on the clipboard (CR-011). */
|
|
||||||
export function debugInfo(playerId: string): string {
|
|
||||||
return [
|
|
||||||
"Deadhead: Space Freight Tycoon",
|
|
||||||
`Version: ${APP_VERSION}`,
|
|
||||||
`Build: ${APP_BUILD}`,
|
|
||||||
`Player: ${playerId}`,
|
|
||||||
`Server time: ${new Date(serverNow()).toISOString()}`,
|
|
||||||
`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`,
|
|
||||||
`Screen: ${lastScreenPath()}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Settings → About: version and build, and debug info for bug reports. */
|
|
||||||
export function About() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="text-ink-400">Deadhead: Space Freight Tycoon</span>
|
|
||||||
<span aria-label="Version" className="font-display text-ink-100 tabular-nums">
|
|
||||||
{APP_VERSION} · build {APP_BUILD}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void navigator.clipboard
|
|
||||||
?.writeText(debugInfo(state.playerId))
|
|
||||||
.then(() => setCopied(true))
|
|
||||||
.catch(() => setCopied(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "Debug info copied" : "Copy debug info"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { useApi } from "../api/context";
|
|
||||||
import { errorMessage } from "../state/actions";
|
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
|
||||||
import { Button, Sheet } from "../ui";
|
|
||||||
|
|
||||||
const BACKUP_KEY = ["backup-code"] as const;
|
|
||||||
|
|
||||||
/** "Back up company": make a code (shown once), or replace the existing one (decision 0.5.72). */
|
|
||||||
export function BackupCompany() {
|
|
||||||
const api = useApi();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const status = useQuery({
|
|
||||||
queryKey: BACKUP_KEY,
|
|
||||||
queryFn: () => api.call("getBackupCode", {}),
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
const create = useMutation({
|
|
||||||
mutationFn: () => api.call("createBackupCode", {}),
|
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: BACKUP_KEY }),
|
|
||||||
});
|
|
||||||
const code = create.data?.code;
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setCopied(false);
|
|
||||||
create.reset();
|
|
||||||
};
|
|
||||||
const copy = (text: string) => {
|
|
||||||
void navigator.clipboard
|
|
||||||
?.writeText(text)
|
|
||||||
.then(() => setCopied(true))
|
|
||||||
.catch(() => setCopied(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
let body;
|
|
||||||
if (code) {
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<p
|
|
||||||
aria-label="Backup code"
|
|
||||||
className="mt-4 rounded-xl bg-space-950/80 p-4 text-center font-mono text-2xl font-bold tracking-widest text-amber-200 ring-1 ring-amber-400/40 select-all"
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
Keep this somewhere safe. Anyone with this code can open your company.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" onClick={() => copy(code)}>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (status.isPending) {
|
|
||||||
body = <p className="mt-4 text-sm text-ink-400">Checking…</p>;
|
|
||||||
} else if (status.data?.hasCode) {
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
You already have a backup code. It is only shown when it is made, so if you lost it, make
|
|
||||||
a new one.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-amber-200">
|
|
||||||
Your old code will stop working as soon as you generate a new one.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
|
||||||
Generate a new code
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<p className="mt-3 text-sm text-ink-200">
|
|
||||||
A backup code lets you open this company on any device, or get it back if this one is
|
|
||||||
lost. No email or password.
|
|
||||||
</p>
|
|
||||||
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
|
||||||
Create a backup code
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Back up company
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<Sheet titleId="backup-title" title="Back up your company" eyebrow="Backup code">
|
|
||||||
{body}
|
|
||||||
{(create.isError || status.isError) && (
|
|
||||||
<p role="alert" className="mt-2 text-sm text-rose-300">
|
|
||||||
{errorMessage(create.error ?? status.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" block className="mt-3" onClick={close}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Restore a company": enter a backup code to open that company on this
|
|
||||||
* device. `pastTutorial` warns that this device's own company is left behind.
|
|
||||||
*/
|
|
||||||
export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
|
|
||||||
const api = useApi();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [code, setCode] = useState("");
|
|
||||||
const restore = useMutation({
|
|
||||||
mutationFn: () => api.call("restoreCompany", { code }),
|
|
||||||
onSuccess: async ({ state }) => {
|
|
||||||
queryClient.setQueryData(PLAYER_STATE_KEY, state);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
|
||||||
setOpen(false);
|
|
||||||
void navigate("/");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Restore a company
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<Sheet titleId="restore-title" title="Restore a company" eyebrow="Backup code" layer="z-30">
|
|
||||||
{pastTutorial && (
|
|
||||||
<p
|
|
||||||
role="note"
|
|
||||||
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
|
|
||||||
>
|
|
||||||
This device'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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { companyNameSchema } from "@deadhead/schema";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
||||||
import { usePreferences } from "../state/preferences";
|
|
||||||
import { Button, Sheet } from "../ui";
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
"w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
|
|
||||||
|
|
||||||
/** The company name, editable in place with the server's own rules (decision 0.5.75). */
|
|
||||||
export function CompanyName() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const rename = useGameAction("renameCompany");
|
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (draft === null) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="text-ink-400">Company name</span>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setDraft(state.company.name)}>
|
|
||||||
Rename
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const check = companyNameSchema.safeParse(draft);
|
|
||||||
const problem = check.success ? null : check.error.issues[0]?.message;
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-2 text-sm"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!check.success) return;
|
|
||||||
rename.mutate({ name: check.data }, { onSuccess: () => setDraft(null) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-ink-400">Company name</span>
|
|
||||||
<input
|
|
||||||
aria-invalid={problem ? true : undefined}
|
|
||||||
aria-describedby="company-name-problem"
|
|
||||||
value={draft}
|
|
||||||
maxLength={40}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
className={`${inputClass} mt-1`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{problem && (
|
|
||||||
<p id="company-name-problem" className="text-rose-300">
|
|
||||||
{problem}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rename.isError && (
|
|
||||||
<p role="alert" className="text-rose-300">
|
|
||||||
{errorMessage(rename.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" type="submit" disabled={!check.success || rename.isPending}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Start a new company": a confirmation that needs the company's name typed in (decision 0.5.74). */
|
|
||||||
export function NewCompany() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const start = useGameAction("newCompany");
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const showTipsAgain = usePreferences((p) => p.showTipsAgain);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [typed, setTyped] = useState("");
|
|
||||||
const name = state.company.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="danger" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Start a new company
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<Sheet titleId="new-company-title" title="Start a new company?" tone="rose" layer="z-30">
|
|
||||||
<p className="mt-2 text-sm text-ink-200">
|
|
||||||
Everything in {name} will be lost: ships, pilots, HQs, credits, the loan and your
|
|
||||||
progress. You start again from the first company run. This can'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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useApi } from "../api/context";
|
|
||||||
import { serverNow, syncClock } from "../clock/clock";
|
|
||||||
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
|
||||||
import { Button } from "../ui";
|
|
||||||
|
|
||||||
const MINUTE = 60_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings → Developer (non-production builds only, CR-011): fast-forward the
|
|
||||||
* server clock, add credits, or land every flight now.
|
|
||||||
*/
|
|
||||||
export function DevTools() {
|
|
||||||
const api = useApi();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const refresh = async () => {
|
|
||||||
await syncClock(api);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
|
||||||
};
|
|
||||||
const advance = useMutation({
|
|
||||||
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
|
|
||||||
onSuccess: refresh,
|
|
||||||
});
|
|
||||||
const credits = useMutation({
|
|
||||||
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
|
|
||||||
onSuccess: refresh,
|
|
||||||
});
|
|
||||||
const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt));
|
|
||||||
const busy = advance.isPending || credits.isPending;
|
|
||||||
const error = advance.error ?? credits.error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => advance.mutate(10 * MINUTE)}
|
|
||||||
>
|
|
||||||
Advance 10 minutes
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => advance.mutate(60 * MINUTE)}
|
|
||||||
>
|
|
||||||
Advance 1 hour
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
|
|
||||||
Add 10,000 credits
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || lastArrival <= serverNow()}
|
|
||||||
onClick={() =>
|
|
||||||
advance.mutate(Math.max(1000, Math.ceil(lastArrival - serverNow()) + 1000))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Finish all flights
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="text-sm text-rose-300">
|
|
||||||
{errorMessage(error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { formatGameTime } from "../clock/GameClock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { cargoName, formatMillionKm, locationName } from "../format";
|
import { cargoName, formatMillionKm, locationName } from "../format";
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
import { resetMapView } from "../map/camera";
|
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||||
import type { MapSelection } from "../map/hitTest";
|
import type { MapSelection } from "../map/hitTest";
|
||||||
import { MapCanvas } from "../map/MapCanvas";
|
import { MapCanvas } from "../map/MapCanvas";
|
||||||
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
|
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
|
||||||
|
|
@ -36,7 +36,7 @@ export function MapScreen() {
|
||||||
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetMapView}
|
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||||
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||||
>
|
>
|
||||||
Reset view
|
Reset view
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { resetMapView } from "../map/camera";
|
import { DEFAULT_CAMERA, useCamera } 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 { useLoadedPlayer } from "../state/actions";
|
||||||
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
||||||
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
@ -23,12 +19,7 @@ export function Settings() {
|
||||||
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
||||||
|
|
||||||
<Section title="Company">
|
<Section title="Company">
|
||||||
<CompanyName />
|
<Row label="Company name">{state.company.name}</Row>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<BackupCompany />
|
|
||||||
<RestoreCompany pastTutorial={state.tutorial === null} />
|
|
||||||
<NewCompany />
|
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Display">
|
<Section title="Display">
|
||||||
|
|
@ -66,7 +57,14 @@ export function Settings() {
|
||||||
|
|
||||||
<Section title="Help">
|
<Section title="Help">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={resetMapView}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
useCamera.setState(DEFAULT_CAMERA);
|
||||||
|
prefs.set({ mapCamera: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
Reset map view
|
Reset map view
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
|
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
|
||||||
|
|
@ -76,12 +74,12 @@ export function Settings() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="About">
|
<Section title="About">
|
||||||
<About />
|
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{isDevBuild() && (
|
{isDevBuild() && (
|
||||||
<Section title="Developer">
|
<Section title="Developer">
|
||||||
<DevTools />
|
<p className="text-sm text-ink-400">Development build tools.</p>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|
@ -100,6 +98,15 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<span className="text-ink-400">{label}</span>
|
||||||
|
<span className="text-right text-ink-100">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A labelled radio group of a few options, styled as a segmented control. */
|
/** A labelled radio group of a few options, styled as a segmented control. */
|
||||||
function Choice<T extends string>(props: {
|
function Choice<T extends string>(props: {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
import { PLAYER_STATE_KEY } from "../state/player";
|
||||||
import { Art, Button, Icon } from "../ui";
|
import { Art, Button, Icon } from "../ui";
|
||||||
import { RestoreCompany } from "./BackupSettings";
|
|
||||||
|
|
||||||
/** First launch: create a guest account (decision: guests first, accounts later). */
|
/** First launch: create a guest account (decision: guests first, accounts later). */
|
||||||
export function StartScreen() {
|
export function StartScreen() {
|
||||||
|
|
@ -59,9 +58,6 @@ export function StartScreen() {
|
||||||
<Icon name="send" className="size-5" />
|
<Icon name="send" className="size-5" />
|
||||||
{start.isPending ? "Starting…" : "Start your company"}
|
{start.isPending ? "Starting…" : "Start your company"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative">
|
|
||||||
<RestoreCompany pastTutorial={false} />
|
|
||||||
</div>
|
|
||||||
{start.isError && (
|
{start.isError && (
|
||||||
<p role="alert" className="relative text-sm text-rose-300">
|
<p role="alert" className="relative text-sm text-rose-300">
|
||||||
Could not start a company. Check your connection and try again.
|
Could not start a company. Check your connection and try again.
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,11 @@ import { formatCredits, formatDuration, locationName } from "../format";
|
||||||
import { goodName } from "../components/Goods";
|
import { goodName } from "../components/Goods";
|
||||||
import { distanceAt, tripRealMs } from "../game";
|
import { distanceAt, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { usePreferences } from "../state/preferences";
|
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
|
||||||
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
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). */
|
/** The company board during the tutorial (Step 9.3). */
|
||||||
export function TutorialBoard() {
|
export function TutorialBoard() {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
@ -29,9 +25,6 @@ export function TutorialBoard() {
|
||||||
const runInFlight = state.tutorial?.runInFlight ?? false;
|
const runInFlight = state.tutorial?.runInFlight ?? false;
|
||||||
const loaner = loanerModel(content);
|
const loaner = loanerModel(content);
|
||||||
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
|
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.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
|
||||||
if (board.isError) {
|
if (board.isError) {
|
||||||
|
|
@ -47,7 +40,7 @@ export function TutorialBoard() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{firstRun && !introDismissed && (
|
{firstRun && (
|
||||||
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
|
<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" />
|
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -56,14 +49,6 @@ export function TutorialBoard() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
||||||
</div>
|
</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>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-ink-300">
|
<p className="text-sm text-ink-300">
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { useClock } from "../src/clock/clock";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
|
||||||
|
|
||||||
describe("about and debug info (Step 10K.10)", () => {
|
|
||||||
it("shows the version and copies debug info with every field", async () => {
|
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
|
||||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
||||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/fleet");
|
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole("link", { name: "Settings" }));
|
|
||||||
const about = await screen.findByRole("region", { name: "About" });
|
|
||||||
// vitest.config.ts stands in for the build-time version and commit.
|
|
||||||
expect(within(about).getByLabelText("Version")).toHaveTextContent("0.0.0-test · build test");
|
|
||||||
|
|
||||||
await userEvent.click(within(about).getByRole("button", { name: "Copy debug info" }));
|
|
||||||
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
|
||||||
const text = (writeText.mock.calls[0] as unknown as [string])[0];
|
|
||||||
expect(text).toContain("Version: 0.0.0-test");
|
|
||||||
expect(text).toContain("Build: test");
|
|
||||||
expect(text).toContain("Player: player-1");
|
|
||||||
expect(text).toMatch(/Server time: \d{4}-\d{2}-\d{2}T/);
|
|
||||||
// The app syncs its own clock, so expect whatever offset it holds now.
|
|
||||||
expect(text).toContain(`Clock offset: ${Math.round(useClock.getState().offsetMs)} ms`);
|
|
||||||
expect(text).toContain("Screen: /fleet");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { ApiError } from "../src/api/client";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
|
|
||||||
|
|
||||||
const CODE = "ABCDE-FGHJK-MNPQR-STUVW";
|
|
||||||
const owner = () => samplePlayerState({ tutorial: null });
|
|
||||||
const restored = () =>
|
|
||||||
samplePlayerState({
|
|
||||||
tutorial: null,
|
|
||||||
company: { id: "c2", name: "Restored Freight", credits: 77_000, loanBalance: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openRestore() {
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Restore a company" }));
|
|
||||||
return screen.getByRole("dialog", { name: "Restore a company" });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("backup and restore (Step 10K.9)", () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
|
||||||
|
|
||||||
it("shows a new code grouped, and Copy puts it on the clipboard", async () => {
|
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
|
||||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: owner,
|
|
||||||
getBackupCode: () => ({ hasCode: false, createdAt: null }),
|
|
||||||
createBackupCode: () => ({ code: CODE, createdAt: 1 }),
|
|
||||||
}),
|
|
||||||
"/settings",
|
|
||||||
);
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Back up company" }));
|
|
||||||
const sheet = screen.getByRole("dialog", { name: "Back up your company" });
|
|
||||||
await userEvent.click(
|
|
||||||
await within(sheet).findByRole("button", { name: "Create a backup code" }),
|
|
||||||
);
|
|
||||||
expect(await within(sheet).findByLabelText("Backup code")).toHaveTextContent(CODE);
|
|
||||||
expect(sheet).toHaveTextContent("Anyone with this code can open your company.");
|
|
||||||
await userEvent.click(within(sheet).getByRole("button", { name: "Copy" }));
|
|
||||||
expect(writeText).toHaveBeenCalledWith(CODE);
|
|
||||||
expect(await within(sheet).findByRole("button", { name: "Copied" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns about an existing code before replacing it", async () => {
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: owner,
|
|
||||||
getBackupCode: () => ({ hasCode: true, createdAt: 1 }),
|
|
||||||
}),
|
|
||||||
"/settings",
|
|
||||||
);
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Back up company" }));
|
|
||||||
const sheet = screen.getByRole("dialog", { name: "Back up your company" });
|
|
||||||
expect(
|
|
||||||
await within(sheet).findByRole("button", { name: "Generate a new code" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(sheet).toHaveTextContent("Your old code will stop working");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns that a company past the tutorial is left behind, and not during the tutorial", async () => {
|
|
||||||
const { unmount } = renderApp(fakeApi({ getPlayerState: owner }), "/settings");
|
|
||||||
expect(await openRestore()).toHaveTextContent("current company will be left behind");
|
|
||||||
unmount();
|
|
||||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/settings");
|
|
||||||
expect(await openRestore()).not.toHaveTextContent("left behind");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores and goes to HQ", async () => {
|
|
||||||
let done = false;
|
|
||||||
const api = fakeApi({
|
|
||||||
getPlayerState: () => (done ? restored() : owner()),
|
|
||||||
restoreCompany: () => {
|
|
||||||
done = true;
|
|
||||||
return { playerId: "p2", state: restored() };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
renderApp(api, "/settings");
|
|
||||||
const sheet = await openRestore();
|
|
||||||
await userEvent.type(within(sheet).getByRole("textbox"), CODE.toLowerCase());
|
|
||||||
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.call).toHaveBeenCalledWith("restoreCompany", { code: CODE.toLowerCase() }),
|
|
||||||
);
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("Credits")).toHaveTextContent("77,000 cr");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a failed restore's error and stays put", async () => {
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: owner,
|
|
||||||
restoreCompany: () => {
|
|
||||||
throw new ApiError(409, "restore-failed", "That code didn't open a company.");
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"/settings",
|
|
||||||
);
|
|
||||||
const sheet = await openRestore();
|
|
||||||
await userEvent.type(within(sheet).getByRole("textbox"), "WRONG");
|
|
||||||
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
|
|
||||||
expect(await within(sheet).findByRole("alert")).toHaveTextContent("didn't open a company");
|
|
||||||
expect(screen.getByRole("dialog", { name: "Restore a company" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers restore on the start screen too", async () => {
|
|
||||||
let done = false;
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: () => (done ? restored() : unauthorized()),
|
|
||||||
restoreCompany: () => {
|
|
||||||
done = true;
|
|
||||||
return { playerId: "p2", state: restored() };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const sheet = await openRestore();
|
|
||||||
expect(sheet).not.toHaveTextContent("left behind");
|
|
||||||
await userEvent.type(within(sheet).getByRole("textbox"), CODE);
|
|
||||||
await userEvent.click(within(sheet).getByRole("button", { name: "Restore" }));
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
|
|
||||||
|
|
||||||
const owner = () =>
|
|
||||||
samplePlayerState({
|
|
||||||
tutorial: null,
|
|
||||||
company: { id: "c", name: "Kell Haulage", credits: 9000, loanBalance: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("company settings (Step 10K.7)", () => {
|
|
||||||
afterEach(() => usePreferences.getState().set(DEFAULT_PREFERENCES));
|
|
||||||
|
|
||||||
it("renames in place, checking the name as you type", async () => {
|
|
||||||
const api = fakeApi({
|
|
||||||
getPlayerState: owner,
|
|
||||||
renameCompany: ({ name }) => ({ company: { ...owner().company, name } }),
|
|
||||||
});
|
|
||||||
renderApp(api, "/settings");
|
|
||||||
const company = await screen.findByRole("region", { name: "Company" });
|
|
||||||
await userEvent.click(within(company).getByRole("button", { name: "Rename" }));
|
|
||||||
const input = within(company).getByRole("textbox");
|
|
||||||
await userEvent.clear(input);
|
|
||||||
await userEvent.type(input, "Ab");
|
|
||||||
expect(within(company).getByText("At least 3 characters")).toBeInTheDocument();
|
|
||||||
expect(within(company).getByRole("button", { name: "Save" })).toBeDisabled();
|
|
||||||
|
|
||||||
await userEvent.type(input, "le Freight ");
|
|
||||||
await userEvent.click(within(company).getByRole("button", { name: "Save" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.call).toHaveBeenCalledWith("renameCompany", { name: "Able Freight" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts over only after the exact company name is typed, then opens the tutorial", async () => {
|
|
||||||
let reset = false;
|
|
||||||
const api = fakeApi({
|
|
||||||
getPlayerState: () => (reset ? samplePlayerState() : owner()),
|
|
||||||
getTutorialBoard: () => sampleTutorialBoard(),
|
|
||||||
newCompany: () => {
|
|
||||||
reset = true;
|
|
||||||
return samplePlayerState();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
usePreferences.getState().dismissTip("tutorial-intro");
|
|
||||||
renderApp(api, "/settings");
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Start a new company" }));
|
|
||||||
const sheet = screen.getByRole("dialog", { name: "Start a new company?" });
|
|
||||||
expect(sheet).toHaveTextContent("Everything in Kell Haulage will be lost");
|
|
||||||
const confirm = within(sheet).getByRole("button", { name: "Start over" });
|
|
||||||
expect(confirm).toBeDisabled();
|
|
||||||
|
|
||||||
const input = within(sheet).getByRole("textbox");
|
|
||||||
await userEvent.type(input, "kell haulage");
|
|
||||||
expect(confirm).toBeDisabled();
|
|
||||||
await userEvent.clear(input);
|
|
||||||
await userEvent.type(input, "Kell Haulage");
|
|
||||||
expect(confirm).toBeEnabled();
|
|
||||||
|
|
||||||
await userEvent.click(confirm);
|
|
||||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {}));
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
|
||||||
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import type { Dispatch } from "@deadhead/schema";
|
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
|
||||||
|
|
||||||
describe("developer tools in Settings (Step 10K.11)", () => {
|
|
||||||
it("advances the clock, adds credits and finishes flights", async () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const flight = {
|
|
||||||
id: "d1",
|
|
||||||
shipId: "loaner",
|
|
||||||
pilotId: "avatar",
|
|
||||||
contract: null,
|
|
||||||
originId: "earth",
|
|
||||||
destinationId: "moon",
|
|
||||||
tutorial: true,
|
|
||||||
departureAt: now - 60_000,
|
|
||||||
arrivalAt: now + 5 * 60_000,
|
|
||||||
distanceMillionKm: 0.4,
|
|
||||||
fuelCost: 0,
|
|
||||||
expectedPayout: 0,
|
|
||||||
seed: 1,
|
|
||||||
status: "in-flight",
|
|
||||||
resolvedAt: null,
|
|
||||||
} as unknown as Dispatch;
|
|
||||||
const api = fakeApi({
|
|
||||||
getPlayerState: () => samplePlayerState({ activeDispatches: [flight] }),
|
|
||||||
devAdvanceClock: ({ advanceMs }) => ({ offsetMs: advanceMs, realTime: now + advanceMs }),
|
|
||||||
devAddCredits: ({ amount }) => ({
|
|
||||||
company: { id: "co1", name: "D", credits: amount, loanBalance: 0 },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
renderApp(api, "/settings");
|
|
||||||
const dev = await screen.findByRole("region", { name: "Developer" });
|
|
||||||
|
|
||||||
await userEvent.click(within(dev).getByRole("button", { name: "Advance 10 minutes" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 600_000 }),
|
|
||||||
);
|
|
||||||
await userEvent.click(within(dev).getByRole("button", { name: "Advance 1 hour" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 3_600_000 }),
|
|
||||||
);
|
|
||||||
await userEvent.click(within(dev).getByRole("button", { name: "Add 10,000 credits" }));
|
|
||||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith("devAddCredits", { amount: 10_000 }));
|
|
||||||
|
|
||||||
await userEvent.click(within(dev).getByRole("button", { name: "Finish all flights" }));
|
|
||||||
await waitFor(() => {
|
|
||||||
const advances = api.call.mock.calls.filter(([name]) => name === "devAdvanceClock");
|
|
||||||
const last = advances.at(-1)![1] as { advanceMs: number };
|
|
||||||
// To just past the last arrival (5 minutes away), give or take the test's own runtime.
|
|
||||||
expect(last.advanceMs).toBeGreaterThan(5 * 60_000 - 5_000);
|
|
||||||
expect(last.advanceMs).toBeLessThan(5 * 60_000 + 5_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { act, screen, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { DEFAULT_CAMERA, pan, useCamera } from "../src/map/camera";
|
|
||||||
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
|
|
||||||
|
|
||||||
describe("help settings (Step 10K.4)", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
act(() => {
|
|
||||||
useCamera.setState(DEFAULT_CAMERA);
|
|
||||||
usePreferences.getState().set(DEFAULT_PREFERENCES);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves the map view, and Reset map view restores the default and forgets it", async () => {
|
|
||||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
||||||
act(() => useCamera.setState((c) => pan({ ...c, zoom: 3 }, 120, -80)));
|
|
||||||
act(() => vi.advanceTimersByTime(400));
|
|
||||||
expect(usePreferences.getState().mapCamera).toEqual({ zoom: 3, panX: 120, panY: -80 });
|
|
||||||
vi.useRealTimers();
|
|
||||||
|
|
||||||
renderApp(
|
|
||||||
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
|
|
||||||
"/settings",
|
|
||||||
);
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Reset map view" }));
|
|
||||||
expect(useCamera.getState()).toEqual(DEFAULT_CAMERA);
|
|
||||||
expect(usePreferences.getState().mapCamera).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("brings a dismissed tip back with Show tips again", async () => {
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: () => samplePlayerState(),
|
|
||||||
getTutorialBoard: () => sampleTutorialBoard(),
|
|
||||||
}),
|
|
||||||
"/contracts",
|
|
||||||
);
|
|
||||||
const intro = await screen.findByLabelText("Intro");
|
|
||||||
await userEvent.click(within(intro).getByRole("button", { name: "Dismiss tip" }));
|
|
||||||
expect(screen.queryByLabelText("Intro")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("link", { name: "Settings" }));
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Show tips again" }));
|
|
||||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
|
||||||
await userEvent.click(within(nav).getByRole("link", { name: /Jobs/ }));
|
|
||||||
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -61,7 +61,6 @@ export function shipExtras(
|
||||||
|
|
||||||
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||||
return {
|
return {
|
||||||
playerId: "player-1",
|
|
||||||
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
||||||
ships: [],
|
ships: [],
|
||||||
pilots: [
|
pilots: [
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { defineConfig, type Plugin } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
|
@ -25,26 +23,7 @@ function omitSourceArt(): Plugin {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Version from package.json and the git commit, shown in Settings → About (CR-011). */
|
|
||||||
function buildInfo() {
|
|
||||||
const { version } = JSON.parse(
|
|
||||||
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
|
||||||
) as {
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
let build = "dev";
|
|
||||||
try {
|
|
||||||
build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
|
||||||
.toString()
|
|
||||||
.trim();
|
|
||||||
} catch {
|
|
||||||
// Not a git checkout (e.g. a tarball build): keep "dev".
|
|
||||||
}
|
|
||||||
return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: buildInfo(),
|
|
||||||
plugins: [react(), tailwindcss(), omitSourceArt()],
|
plugins: [react(), tailwindcss(), omitSourceArt()],
|
||||||
server: {
|
server: {
|
||||||
// Same-domain API in development too (decision 0.5.20). The server listens on
|
// Same-domain API in development too (decision 0.5.20). The server listens on
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { defineProject } from "vitest/config";
|
||||||
|
|
||||||
export default defineProject({
|
export default defineProject({
|
||||||
plugins: [react()],
|
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: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["./test/setup.ts"],
|
setupFiles: ["./test/setup.ts"],
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 682 KiB |
Binary file not shown.
|
Before 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` |
|
| `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` |
|
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||||
| `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
|
|
@ -62,13 +62,6 @@ The `routes` catalog is the single source for both server and client. Each entry
|
||||||
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
||||||
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
|
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
|
||||||
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
|
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
|
||||||
| `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 |
|
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
|
||||||
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
|
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
|
||||||
|
|
||||||
|
|
@ -81,7 +74,6 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
||||||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
|
| `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` |
|
| `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) | — |
|
| `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) |
|
| `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) |
|
| `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`) |
|
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
||||||
- **Test:** A test asserts every endpoint has request and response schemas and that no two endpoints share a method and path.
|
- **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
|
## Phase 5 — Server Foundation
|
||||||
|
|
||||||
|
|
@ -339,7 +338,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
||||||
- **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3).
|
- **Amended by CR-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-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-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
|
### 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.).
|
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
|
||||||
|
|
@ -451,7 +449,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
||||||
- Create the React app in `apps/web` with Tailwind, a basic layout (header with credits and game clock, main area, bottom navigation for mobile), and routing for: HQ, Contracts, Fleet, Pilots, Market, Locations.
|
- 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.
|
- **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-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
|
### Step 8.2 — Typed API client
|
||||||
- Build an API client from the route catalog so every call is typed and responses are validated.
|
- Build an API client from the route catalog so every call is typed and responses are validated.
|
||||||
|
|
@ -465,7 +462,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
||||||
- On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session.
|
- 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.
|
- **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-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
|
### 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.
|
- Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes.
|
||||||
|
|
@ -633,7 +629,6 @@ Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settin
|
||||||
- **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled.
|
- **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
|
## Phase 12 — Balance Harness and End-to-End Tests
|
||||||
|
|
||||||
|
|
@ -657,7 +652,6 @@ Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settin
|
||||||
- Playwright test against a real server and test database, with the server clock controllable via a test-only setting: start as guest → fly tutorial runs (advancing the clock) until affordable → buy a Courier → lease Earth bay → tutorial ends.
|
- 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.
|
- **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-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
|
### Step 12.4 — End-to-end: offline progress
|
||||||
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen.
|
- 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.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.2 — Client: settings screen and entry point.** A gear link (`aria-label="Settings"`, new `gear` icon) at the right of the header opens `/settings` (a route inside the layout, not a nav tab). `screens/Settings.tsx`: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when `import.meta.env.PROD` is false. Remaining items arrive with their steps. Test: `settings.test.tsx`.
|
||||||
- [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets `<html data-reduced-motion>`, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults.
|
- [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets `<html data-reduced-motion>`, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults.
|
||||||
- [x] **Step 10K.4 — Client: reset map view and show tips again.** The map camera starts from the saved view and saves itself (throttled to 300 ms) as the player pans and zooms; `resetMapView()` restores the default and clears the saved view (Settings and the map's "Reset view"). The tutorial intro card is now a one-time tip (`tutorial-intro`) with a dismiss button; "Show tips again" clears dismissed tips. Test: `help-settings.test.tsx`.
|
- [ ] Step 10K.4 — Client: reset map view and show tips again
|
||||||
- [x] **Step 10K.5 — Server: rename company.** `companyNameSchema` (trimmed; 3–32 of letters, digits, spaces and `. , ' & ! ? -`) and route `renameCompany` (`POST /api/company/name`, `{ name }` → `{ company }`), run in `withPlayer`. New companies get a default name from their world seed (`companyName(seed)`, e.g. "Lodestar Haulage") instead of the fixed "Deadhead Freight". Player state already carried the name. Test: `company-name.test.ts`.
|
- [ ] Step 10K.5 — Server: rename company
|
||||||
- [x] **Step 10K.6 — Server: start a new company.** Route `newCompany` (`POST /api/company/new`, `{}` → player state) runs `newCompany` in `withPlayer` (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls `createStartingState`, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: `new-company.test.ts` (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8.
|
- [ ] Step 10K.6 — Server: start a new company
|
||||||
- [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`.
|
- [ ] Step 10K.7 — Client: rename and new company flows
|
||||||
- [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).
|
- [ ] Step 10K.8 — Server: backup code and restore
|
||||||
- [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`.
|
- [ ] Step 10K.9 — Client: backup and restore flows
|
||||||
- [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`.
|
- [ ] Step 10K.10 — Client: About and debug info
|
||||||
- [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).
|
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
|
||||||
- [x] **Step 10K.12 — Plan amendments and regression.** CR-011 §6 amendments added to plan steps 4.2, 5.3, 8.1, 8.4, 11.2 and 12.3 (for 12.3–12.6); §0.3 and §0.5.71–76 were set with the CR. Typecheck, lint, full suite with coverage and the web build are green. Scripted manual check against a live server (scratchpad `cr011-smoke.mjs`, two cookie jars as two devices): a renamed company with 50,500 cr was backed up on one and restored on the other (same player, name and credits); starting a new company on the second device showed the fresh company on the first after a refresh; the same backup code still opened it. Screenshot: `docs/screenshots/10k12-settings-{phone,desktop}.png`. Also in this phase: the phone header fix from the user's report. **Phase 10K complete — stopped for review.**
|
- [ ] Step 10K.12 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,6 @@ export const goalStateSchema = z.object({
|
||||||
export type GoalState = z.infer<typeof goalStateSchema>;
|
export type GoalState = z.infer<typeof goalStateSchema>;
|
||||||
|
|
||||||
export const playerStateSchema = z.object({
|
export const playerStateSchema = z.object({
|
||||||
/** This player (debug info, CR-011). */
|
|
||||||
playerId: idSchema,
|
|
||||||
company: companySchema,
|
company: companySchema,
|
||||||
ships: z.array(shipSchema),
|
ships: z.array(shipSchema),
|
||||||
pilots: z.array(pilotSchema),
|
pilots: z.array(pilotSchema),
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ export const SERVER_ERROR_CODES = [
|
||||||
"unknown-goal",
|
"unknown-goal",
|
||||||
"goal-not-ready",
|
"goal-not-ready",
|
||||||
"goal-already-claimed",
|
"goal-already-claimed",
|
||||||
"restore-failed",
|
|
||||||
"rate-limited",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
|
|
|
||||||
|
|
@ -81,44 +81,3 @@ export const acknowledgeSummariesResponseSchema = z.object({
|
||||||
export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() });
|
export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() });
|
||||||
export const claimGoalRequestSchema = z.object({ goalId: idSchema });
|
export const claimGoalRequestSchema = z.object({ goalId: idSchema });
|
||||||
export const companyResponseSchema = z.object({ company: companySchema });
|
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,13 +17,6 @@ import {
|
||||||
hirePilotRequestSchema,
|
hirePilotRequestSchema,
|
||||||
leaseBayRequestSchema,
|
leaseBayRequestSchema,
|
||||||
pilotResponseSchema,
|
pilotResponseSchema,
|
||||||
backupCodeResponseSchema,
|
|
||||||
devAddCreditsRequestSchema,
|
|
||||||
devAdvanceClockRequestSchema,
|
|
||||||
devAdvanceClockResponseSchema,
|
|
||||||
backupCodeStatusSchema,
|
|
||||||
renameCompanyRequestSchema,
|
|
||||||
restoreRequestSchema,
|
|
||||||
repayLoanRequestSchema,
|
repayLoanRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
|
|
@ -158,56 +151,6 @@ export const routes = {
|
||||||
response: playerStateSchema,
|
response: playerStateSchema,
|
||||||
auth: true,
|
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: {
|
repayLoan: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/loan/repay",
|
path: "/api/loan/repay",
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,6 @@ describe("route catalog", () => {
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"acknowledgeSummaries",
|
"acknowledgeSummaries",
|
||||||
"acceptDeal",
|
"acceptDeal",
|
||||||
"getBackupCode",
|
|
||||||
"createBackupCode",
|
|
||||||
"restoreCompany",
|
|
||||||
"devAdvanceClock",
|
|
||||||
"devAddCredits",
|
|
||||||
"newCompany",
|
|
||||||
"renameCompany",
|
|
||||||
"repayLoan",
|
"repayLoan",
|
||||||
"claimGoal",
|
"claimGoal",
|
||||||
].sort(),
|
].sort(),
|
||||||
|
|
@ -63,9 +56,8 @@ describe("route catalog", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a session everywhere except server time, guest creation and restore", () => {
|
it("requires a session everywhere except server time and guest creation", () => {
|
||||||
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
||||||
// Restoring signs a device in, so it runs without a session (CR-011).
|
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
|
||||||
expect(open.sort()).toEqual(["createGuest", "getServerTime", "restoreCompany"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
acknowledgeSummariesRequestSchema,
|
acknowledgeSummariesRequestSchema,
|
||||||
acknowledgeSummariesResponseSchema,
|
acknowledgeSummariesResponseSchema,
|
||||||
renameCompanyRequestSchema,
|
|
||||||
startUpgradeRequestSchema,
|
startUpgradeRequestSchema,
|
||||||
assignPilotRequestSchema,
|
assignPilotRequestSchema,
|
||||||
baySchema,
|
baySchema,
|
||||||
|
|
@ -152,7 +151,6 @@ const tutorial = {
|
||||||
};
|
};
|
||||||
const goal = { id: "first-hire", status: "ready" };
|
const goal = { id: "first-hire", status: "ready" };
|
||||||
const playerState = {
|
const playerState = {
|
||||||
playerId: "player-1",
|
|
||||||
company,
|
company,
|
||||||
ships: [ship],
|
ships: [ship],
|
||||||
pilots: [pilot],
|
pilots: [pilot],
|
||||||
|
|
@ -185,7 +183,6 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||||
{ ...ship, shop: null },
|
{ ...ship, shop: null },
|
||||||
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
|
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
|
||||||
],
|
],
|
||||||
["rename company request", renameCompanyRequestSchema, { name: "Kell & Co." }, { name: "Hi" }],
|
|
||||||
[
|
[
|
||||||
"start upgrade request",
|
"start upgrade request",
|
||||||
startUpgradeRequestSchema,
|
startUpgradeRequestSchema,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue