Compare commits

..

5 Commits

Author SHA1 Message Date
fireshaper 621eecb737 Fix the header on phones: credits and clock no longer overlap
CI / check (push) Successful in 4m37s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:17:41 -04:00
fireshaper 754242dde6 Step 10K.3: clock, countdown and reduced-motion settings applied everywhere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:11:34 -04:00
fireshaper 74cc29c8e3 Step 10K.2: settings screen behind a header gear
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:01:02 -04:00
fireshaper c73c9a4116 Step 10K.1: device preferences store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:57:11 -04:00
fireshaper 5813fce556 CR-011: record settings screen and company backup change request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:53:11 -04:00
29 changed files with 917 additions and 56 deletions

View File

@ -1033,3 +1033,35 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **CR-001's text is amended in place** where the CR asks (goal 4, the deal, the modal, and the "hire first pilot" target now "buy a second ship"), with a note of what changed, so its history stays readable.
- **The manual check was scripted** (`cr010-smoke.mjs`); its numbers confirm the deduction order: (1118 − 230) × 0.25 = 222 to the pilot, then 30 % of the remaining 896 (268, rounded down) to the lender, 628 credited.
## CR-011 — Settings Screen and Company Backup (Phase 10K)
- **Renumbered on arrival:** titled "CR-008 … Phase 10H"; CR-008 to CR-010 and Phases 10H–10J already exist, so it is **CR-011**, done as **Phase 10K** (its 10H.n are 10K.n).
- **Decisions** 0.5.53–0.5.58 are recorded as **0.5.71–0.5.76**; "account linking" is added to §0.3 (deferred).
## Step 10K.1
- **Reduced motion is stored as an override** (`null` = follow the device), so a player who never touches it keeps tracking their OS setting, as decision 0.5.76 asks.
- **Clock default is `locale`** (the device's own 12/24-hour habit) rather than a fixed format.
- **Saved data is validated field by field** on load; anything unknown or broken falls back to that field's default instead of discarding everything.
- **The store is built by a factory** (`createPreferencesStore(storage)`) so tests can simulate a reload or a throwing storage; the app uses one instance on `localStorage`.
## Step 10K.2
- **Settings is a route (`/settings`) inside the normal layout**, reached only from the header gear, so the seven-tab navigation is unchanged and the back button works.
- **Reduced motion is a three-way choice (Device / On / Off)**: "Device" stores no override, so the default keeps following the OS (decision 0.5.76).
- **Items appear with the step that makes them work** (rename and new company, backup, version, developer tools), so no placeholder controls ship.
## Step 10K.3
- **The default "device" clock changes how the game clock looks** on 12-hour devices ("2145-07-11 7:18 PM"); before, it was always 24-hour. That is decision 0.5.76's default; players who prefer 24-hour pick it in Settings.
- **Countdowns default to real time everywhere, Priority windows included.** CR-007's chip showed game hours and switched to real time on tap; it now shows the chosen countdown format, with both in its tooltip, so one setting governs every span.
- **Game-time spans read "4.3 game h"** so they can't be mistaken for real hours next to a real countdown.
- **Reduced motion has two sources**: the OS media query (unless the player turned motion back on) and the player's override. The map keeps moving ships but stills its background.
- **Pure helpers read preferences directly** (`displayPreferences()`) where they format strings outside React (the dispatch sheet's problem lines); those re-render every second with the clock anyway.
## Fix: header on phones (user report during Phase 10K)
- **User report:** "On a phone screen the credits and time line can't be read" (screenshot at about 430 px): the credits chip was drawn over the DEADHEAD wordmark. The wordmark showed from 420 px, and 10K.2's gear plus 10K.3's device-locale clock ("2145-07-11 7:18 PM") made the right side wider than the space left.
- **Fix:** the wordmark now shows from 640 px (`sm`), the logo side clips instead of overflowing, the game clock shows only the time below 640 px (the date returns above), chips sit a little closer, and the gear is 32 px on phones. The gear icon is now a cog (it read as a sun).
- **Checked** in headless Chrome at 375, 390 and 430 px with a 1,234,567 cr balance and a 12-hour clock: no overlapping header items and no horizontal scroll (`docs/screenshots/header-phone-390.png`).

View File

@ -14,6 +14,8 @@ import { HQ } from "./screens/HQ";
import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { Button } from "./ui";
@ -49,6 +51,7 @@ function useServerClock() {
/** The whole client. Router, API and query providers come from the caller. */
export function App() {
useServerClock();
useMotionPreference();
const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch);
const retry = () => void state.refetch();
@ -106,6 +109,8 @@ export function App() {
element={SCREEN_COMPONENTS[screen.path]}
/>
))}
{/* Settings opens from the header gear, not a tab (CR-011). */}
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
<AwaySummary />

View File

@ -1,14 +1,21 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { usePreferences } from "../state/preferences";
import { useClock } from "./clock";
import { formatGameTime } from "./timeFormat";
/** "2100-03-14 08:00": the in-game date and hour, in UTC. */
export function formatGameTime(gameTime: number): string {
return new Date(gameTime).toISOString().slice(0, 16).replace("T", " ");
}
export { formatGameTime };
/** The ticking in-game clock shown in the header. */
/** The ticking in-game clock shown in the header, in the player's clock format. */
export function GameClock() {
const now = useClock((state) => state.now);
return <time>{formatGameTime(realToGame(now, content.config))}</time>;
const clock = usePreferences((p) => p.clock);
const [date, ...time] = formatGameTime(realToGame(now, content.config), clock).split(" ");
// Phones show the time only; the date joins it from 640 px (it no longer fit).
return (
<time>
<span className="hidden sm:inline">{date} </span>
{time.join(" ")}
</time>
);
}

View File

@ -0,0 +1,68 @@
import { content } from "@deadhead/content";
import { MS_PER_HOUR } from "@deadhead/sim";
import { formatDuration } from "../format";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
/**
* The one place times and spans are formatted for display (CR-011): clock
* times follow the clock preference, spans follow the countdown preference.
*/
function hour12For(clock: ClockFormat): boolean | undefined {
return clock === "locale" ? undefined : clock === "12h";
}
/** A real time of day on this device, e.g. "14:05" or "2:05 PM". */
export function formatTimeOfDay(time: number, clock: ClockFormat): string {
return new Date(time).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: hour12For(clock),
});
}
/** The in-game date and time (UTC), e.g. "2100-03-14 08:00" or "2100-03-14 8:00 AM". */
export function formatGameTime(gameTime: number, clock: ClockFormat = "24h"): string {
const date = new Date(gameTime).toISOString().slice(0, 10);
if (clock === "24h") return `${date} ${new Date(gameTime).toISOString().slice(11, 16)}`;
const time = new Date(gameTime).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
hour12: hour12For(clock),
timeZone: "UTC",
});
return `${date} ${time}`;
}
/** A span of game hours, e.g. "4.3 game h" or "62 game h". */
export function formatGameSpan(gameHours: number): string {
const rounded = gameHours < 10 ? gameHours.toFixed(1) : String(Math.round(gameHours));
return `${rounded} game h`;
}
/**
* A span of real milliseconds as the player wants countdowns: real time
* ("4m 16s"), game time ("4.3 game h") or both ("4m 16s · 4.3 game h").
* `real` formats the real part (e.g. mm:ss for the board refresh).
*/
export function formatSpan(
ms: number,
mode: CountdownFormat,
real: (ms: number) => string = formatDuration,
): string {
const game = formatGameSpan((Math.max(0, ms) * content.config.timeCompression) / MS_PER_HOUR);
if (mode === "game") return game;
if (mode === "both") return `${real(ms)} · ${game}`;
return real(ms);
}
/** A span given in game hours (e.g. a Priority window), in the countdown mode. */
export function formatGameHoursSpan(gameHours: number, mode: CountdownFormat): string {
return formatSpan((gameHours * MS_PER_HOUR) / content.config.timeCompression, mode);
}
/** The current preferences, for formatting outside React components. */
export function displayPreferences(): { clock: ClockFormat; countdown: CountdownFormat } {
const { clock, countdown } = usePreferences.getState();
return { clock, countdown };
}

View File

@ -1,7 +1,13 @@
import { useClock } from "../clock/clock";
import { formatSpan } from "../clock/timeFormat";
import { formatDuration } from "../format";
import { usePreferences } from "../state/preferences";
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */
/**
* Live time remaining until `to` (server clock), in the player's countdown
* format (real, game or both; CR-011); shows `done` once it has passed.
* `format` styles the real-time part (e.g. mm:ss).
*/
export function Countdown({
to,
done = "Arriving…",
@ -12,6 +18,11 @@ export function Countdown({
format?: (ms: number) => string;
}) {
const now = useClock((s) => s.now);
const mode = usePreferences((p) => p.countdown);
const remaining = to - now;
return <span className="tabular-nums">{remaining > 0 ? format(remaining) : done}</span>;
return (
<span className="tabular-nums">
{remaining > 0 ? formatSpan(remaining, mode, format) : done}
</span>
);
}

View File

@ -1,8 +1,9 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
import { formatGameHoursSpan } from "../clock/timeFormat";
import { usePreferences } from "../state/preferences";
import { shipStatsOf } from "../game";
import { useState } from "react";
import { cargoName, formatDuration } from "../format";
import { Pill, type Tone } from "../ui";
@ -41,25 +42,20 @@ export function ClassChip({ contract }: { contract: Pick<Contract, "cargoTypeId"
}
/**
* "PRIORITY · within 5h" (game hours). Tap to switch to real time; hovering
* shows both.
* "PRIORITY · within 5m 00s": the delivery window, in the player's countdown
* format (CR-011); hovering shows game and real time.
*/
export function PriorityChip({ windowHours }: { windowHours: number }) {
const [real, setReal] = useState(false);
const mode = usePreferences((p) => p.countdown);
return (
<button
type="button"
onClick={() => setReal(!real)}
<span
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
className="rounded-full"
>
<Pill tone="amber" icon="bolt">
Priority · within{" "}
<span className="normal-case">
{real ? realTimeOf(windowHours) : formatWindow(windowHours)}
</span>
<span className="normal-case">{formatGameHoursSpan(windowHours, mode)}</span>
</Pill>
</button>
</span>
);
}

View File

@ -95,13 +95,24 @@
}
}
/* Motion is decoration only: switch it off when asked. */
/*
* Motion is decoration only: switch it off when asked. The app sets
* data-reduced-motion on <html> from the player's setting (CR-011); without
* an override it follows the device.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
:root:not([data-reduced-motion="false"]) *,
:root:not([data-reduced-motion="false"]) *::before,
:root:not([data-reduced-motion="false"]) *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
:root[data-reduced-motion="true"] *,
:root[data-reduced-motion="true"] *::before,
:root[data-reduced-motion="true"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
import { Link, NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui";
@ -28,16 +28,17 @@ export function Layout({
return (
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
<div className="flex min-w-0 items-center gap-2">
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<LogoMark />
<span className="hidden font-display text-lg font-bold tracking-[0.28em] min-[420px]:inline">
<span className="hidden font-display text-lg font-bold tracking-[0.28em] sm:inline">
DEADHEAD
</span>
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
Space Freight
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber">
<Icon name="credits" className="size-4 text-amber-300" />
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
@ -50,6 +51,13 @@ export function Layout({
{clock}
</span>
</div>
<Link
to="/settings"
aria-label="Settings"
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9"
>
<Icon name="gear" className="size-5" />
</Link>
</div>
</header>
{banner}

View File

@ -310,3 +310,11 @@ export function decorate(
}
return result;
}
/**
* The map style with motion settings applied (CR-011): reduced motion keeps the
* background still (no parallax, no zoom follow). Ships still move.
*/
export function mapMotionStyle(reducedMotion: boolean, style: MapStyle = mapStyle): MapStyle {
return reducedMotion ? { ...style, parallax: 0, backgroundZoomFollow: 0 } : style;
}

View File

@ -2,7 +2,8 @@ import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } fr
import type { ArtTextures } from "./art/load";
import { sceneVisuals, type Visual } from "./art/visuals";
import { toScreen, type Camera, type Viewport } from "./camera";
import { backgroundRect, decorate, layoutScene, polar } from "./layout";
import { reducedMotionOn, usePreferences } from "../state/preferences";
import { backgroundRect, decorate, layoutScene, mapMotionStyle, polar } from "./layout";
import type { MapSelection } from "./hitTest";
import { locationOverlays } from "./overlays";
import type { MapPoint } from "./projection";
@ -136,7 +137,8 @@ export class MapRenderer {
for (const sprite of this.sprites.values()) sprite.visible = false;
if (this.background) {
const r = backgroundRect(view, camera, this.background.texture);
const style = mapMotionStyle(reducedMotionOn(usePreferences.getState()));
const r = backgroundRect(view, camera, this.background.texture, style);
this.background.position.set(r.x, r.y);
this.background.setSize(r.width, r.height);
}

View File

@ -31,7 +31,14 @@ import { location, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
import { formatWindow, goodName, PriorityChip } from "../components/Goods";
import { goodName, PriorityChip } from "../components/Goods";
import {
displayPreferences,
formatGameHoursSpan,
formatSpan,
formatTimeOfDay,
} from "../clock/timeFormat";
import { usePreferences } from "../state/preferences";
/** Short player-facing reasons a ship+pilot cannot take a contract. */
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
@ -68,7 +75,9 @@ export function explainProblems(
const problems: string[] = [];
// Resting blocks flying (CR-004): first, with the time left.
if (pilot.resting && pilot.restEndsAt !== null) {
problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`);
problems.push(
`Resting — ${formatSpan(pilot.restEndsAt - now, displayPreferences().countdown, formatClock)}`,
);
}
if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
if (contract.massTonnes > model.cargoCapacityTonnes) {
@ -92,7 +101,9 @@ export function explainProblems(
if (window !== null) {
const trip = contractTripGameHours(model, contract, now, content);
if (trip > window) {
problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} window`);
problems.push(
`Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`,
);
}
}
if (contract.requiredSkill) {
@ -116,7 +127,7 @@ export function explainProblems(
/** Real-world arrival time on the device, e.g. "14:05". */
export function formatLocalTime(time: number): string {
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return formatTimeOfDay(time, displayPreferences().clock);
}
export interface PairOption {
@ -201,7 +212,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
// In the shop (CR-009): first, with the time left.
if (isInShop(ship, now))
all.unshift(`In the shop — ${formatClock(ship.inShopUntil! - now)}`);
all.unshift(
`In the shop — ${formatSpan(ship.inShopUntil! - now, displayPreferences().countdown, formatClock)}`,
);
problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]];
}
}
@ -233,6 +246,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
/** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */
export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
const state = useLoadedPlayer();
const prefsCountdown = usePreferences((p) => p.countdown);
const now = useClock((s) => s.now);
const dispatch = useGameAction("createDispatch");
const options = pairOptions(state, contract, now);
@ -339,7 +353,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell
label="ETA (game)"
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
value={formatGameTime(
realToGame(option.arrivalAt, content.config),
displayPreferences().clock,
)}
/>
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
{option.duty && (
@ -352,9 +369,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
spare in the {formatWindow(option.priority.windowHours)} window
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
trip,{" "}
{formatGameHoursSpan(
option.priority.windowHours - option.priority.tripHours,
prefsCountdown,
)}{" "}
to spare in the{" "}
{formatGameHoursSpan(option.priority.windowHours, prefsCountdown)} window
</p>
)}
{!blocked && option.duty?.willRest && (

View File

@ -10,6 +10,7 @@ import { LoanCard } from "../components/LoanCard";
import { RouteStrip } from "../components/RouteStrip";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
/** What the player needs to do next to reach the deal (decision 0.5.30). */
@ -27,6 +28,7 @@ export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): s
export function HQ() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const clockFormat = usePreferences((p) => p.clock);
const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
@ -56,7 +58,7 @@ export function HQ() {
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config))}
value={formatGameTime(realToGame(now, content.config), clockFormat)}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />

View File

@ -12,6 +12,7 @@ import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Art, buttonClass, Icon } from "../ui";
const TYPE_LABELS: Record<Location["type"], string> = {
@ -157,6 +158,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
/** A tapped ship in flight: its route, pilot and ETA. */
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const clockFormat = usePreferences((p) => p.clock);
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
if (!dispatch) return null;
const shipName = dispatch.tutorial
@ -183,7 +185,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
<span className="font-display text-cyan-200">
<Countdown to={dispatch.arrivalAt} />
</span>{" "}
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})
</p>
</Panel>
);

View File

@ -0,0 +1,140 @@
import type { ReactNode } from "react";
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui";
/** Developer tools only exist in non-production builds (CR-011). */
export function isDevBuild(): boolean {
return !import.meta.env.PROD;
}
/** Settings (CR-011): company, display, help, about and, in development, developer tools. */
export function Settings() {
const state = useLoadedPlayer();
const prefs = usePreferences();
return (
<Screen className="max-w-2xl">
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company">
<Row label="Company name">{state.company.name}</Row>
</Section>
<Section title="Display">
<Choice<ClockFormat>
label="Clock"
value={prefs.clock}
options={[
["locale", "Device"],
["12h", "12-hour"],
["24h", "24-hour"],
]}
onChange={(clock) => prefs.set({ clock })}
/>
<Choice<CountdownFormat>
label="Countdowns"
value={prefs.countdown}
options={[
["real", "Real time"],
["game", "Game time"],
["both", "Both"],
]}
onChange={(countdown) => prefs.set({ countdown })}
/>
<Choice<"device" | "on" | "off">
label="Reduced motion"
value={prefs.reducedMotion === null ? "device" : prefs.reducedMotion ? "on" : "off"}
options={[
["device", "Device"],
["on", "On"],
["off", "Off"],
]}
onChange={(v) => prefs.set({ reducedMotion: v === "device" ? null : v === "on" })}
/>
</Section>
<Section title="Help">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
useCamera.setState(DEFAULT_CAMERA);
prefs.set({ mapCamera: null });
}}
>
Reset map view
</Button>
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
Show tips again
</Button>
</div>
</Section>
<Section title="About">
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
</Section>
{isDevBuild() && (
<Section title="Developer">
<p className="text-sm text-ink-400">Development build tools.</p>
</Section>
)}
</Screen>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const id = `settings-${title.toLowerCase()}`;
return (
<Panel as="section" aria-labelledby={id} className="space-y-3">
<h2 id={id} className="font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
{title}
</h2>
{children}
</Panel>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-ink-400">{label}</span>
<span className="text-right text-ink-100">{children}</span>
</div>
);
}
/** A labelled radio group of a few options, styled as a segmented control. */
function Choice<T extends string>(props: {
label: string;
value: T;
options: [T, string][];
onChange: (value: T) => void;
}) {
return (
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
<legend className="float-left text-ink-400">{props.label}</legend>
<div className="flex overflow-hidden rounded-lg border border-space-600">
{props.options.map(([value, text]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
>
<input
type="radio"
name={props.label}
value={value}
checked={props.value === value}
onChange={() => props.onChange(value)}
className="sr-only"
/>
{text}
</label>
))}
</div>
</fieldset>
);
}

View File

@ -0,0 +1,148 @@
import { useEffect } from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";
import type { Camera } from "../map/camera";
/**
* Device preferences (CR-011, decision 0.5.76): display and help settings kept
* on this device only. Every storage access is wrapped, so the app runs on
* defaults when storage is blocked, full or broken.
*/
export type ClockFormat = "locale" | "12h" | "24h";
export type CountdownFormat = "real" | "game" | "both";
export interface Preferences {
clock: ClockFormat;
countdown: CountdownFormat;
/** Reduced motion override; null follows the device setting. */
reducedMotion: boolean | null;
/** One-time tips the player has dismissed, by id. */
dismissedTips: string[];
/** The map view to restore; null for the default view. */
mapCamera: Camera | null;
}
export const DEFAULT_PREFERENCES: Preferences = {
clock: "locale",
countdown: "real",
reducedMotion: null,
dismissedTips: [],
mapCamera: null,
};
/** The part of `Storage` the store needs (a fake in tests). */
export interface PreferenceStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export const PREFERENCES_KEY = "deadhead:preferences:v1";
/** localStorage if this device allows it; null when it doesn't. */
function deviceStorage(): PreferenceStorage | null {
try {
return typeof localStorage === "undefined" ? null : localStorage;
} catch {
return null;
}
}
/** Saved preferences merged over the defaults; the defaults on any problem. */
export function loadPreferences(storage: PreferenceStorage | null): Preferences {
try {
const raw = storage?.getItem(PREFERENCES_KEY);
if (!raw) return DEFAULT_PREFERENCES;
const saved = JSON.parse(raw) as Partial<Preferences>;
return {
clock: ["locale", "12h", "24h"].includes(saved.clock as string)
? (saved.clock as ClockFormat)
: DEFAULT_PREFERENCES.clock,
countdown: ["real", "game", "both"].includes(saved.countdown as string)
? (saved.countdown as CountdownFormat)
: DEFAULT_PREFERENCES.countdown,
reducedMotion: typeof saved.reducedMotion === "boolean" ? saved.reducedMotion : null,
dismissedTips: Array.isArray(saved.dismissedTips)
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
: [],
mapCamera:
saved.mapCamera &&
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
? saved.mapCamera
: null,
};
} catch {
return DEFAULT_PREFERENCES;
}
}
function savePreferences(storage: PreferenceStorage | null, prefs: Preferences): void {
try {
storage?.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
} catch {
// Storage blocked or full: keep going in memory.
}
}
/** Whether the device asks for reduced motion. */
export function deviceReducesMotion(): boolean {
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
} catch {
return false;
}
}
export interface PreferencesStore extends Preferences {
set(change: Partial<Preferences>): void;
dismissTip(id: string): void;
showTipsAgain(): void;
}
/** A preferences store backed by `storage` (device localStorage by default). */
export function createPreferencesStore(
storage: PreferenceStorage | null = deviceStorage(),
): UseBoundStore<StoreApi<PreferencesStore>> {
return create<PreferencesStore>((setState, getState) => {
const persist = () => {
const { clock, countdown, reducedMotion, dismissedTips, mapCamera } = getState();
savePreferences(storage, { clock, countdown, reducedMotion, dismissedTips, mapCamera });
};
return {
...loadPreferences(storage),
set(change) {
setState(change);
persist();
},
dismissTip(id) {
if (!getState().dismissedTips.includes(id)) {
setState({ dismissedTips: [...getState().dismissedTips, id] });
persist();
}
},
showTipsAgain() {
setState({ dismissedTips: [] });
persist();
},
};
});
}
export const usePreferences = createPreferencesStore();
/** Reduced motion in effect: the player's override, else the device setting. */
export function reducedMotionOn(prefs: Pick<Preferences, "reducedMotion">): boolean {
return prefs.reducedMotion ?? deviceReducesMotion();
}
/**
* Mirrors the reduced-motion override onto `<html data-reduced-motion>`, which
* the stylesheet uses; no attribute means "follow the device".
*/
export function useMotionPreference(): void {
const override = usePreferences((p) => p.reducedMotion);
useEffect(() => {
const root = document.documentElement;
if (override === null) delete root.dataset.reducedMotion;
else root.dataset.reducedMotion = String(override);
}, [override]);
}

View File

@ -82,6 +82,13 @@ const PATHS = {
</>
),
close: <path d="M6 6l12 12M18 6 6 18" />,
// A cog (outline after Lucide "settings", ISC licence).
gear: (
<>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
target: (
<>

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { act, render, screen } from "@testing-library/react";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ApiClient } from "../src/api/client";
import { serverNow, startClockTicker, syncClock, useClock } from "../src/clock/clock";
@ -51,13 +51,16 @@ describe("server-synced clock", () => {
it("shows the in-game time in the game clock and keeps it ticking", async () => {
await syncClock(serverAhead(0));
const stop = startClockTicker();
render(<GameClock />);
const at = (real: number) => formatGameTime(realToGame(real, content.config));
expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
const { container } = render(<GameClock />);
// Date and time are separate spans (the date hides on phones).
const clock = () => container.querySelector("time")!;
// The default clock follows the device locale (CR-011).
const at = (real: number) => formatGameTime(realToGame(real, content.config), "locale");
expect(clock()).toHaveTextContent(at(DEVICE_NOW));
// One real minute is one game hour.
act(() => vi.advanceTimersByTime(60_000));
expect(screen.getByText(at(DEVICE_NOW + 60_000))).toBeInTheDocument();
expect(clock()).toHaveTextContent(at(DEVICE_NOW + 60_000));
stop();
});
});

View File

@ -1,6 +1,5 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { realTimeOf } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
@ -78,9 +77,8 @@ describe("contract card (CR-007)", () => {
const c = await card("3,210 cr");
// A Priority good can have its own name.
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
const chip = within(c).getByRole("button", { name: /Priority · within 5h/ });
await userEvent.click(chip);
expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
// The window follows the countdown preference: real time by default (CR-011).
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow");
});

View File

@ -4,8 +4,8 @@ import { contractTripGameHours, priorityWindow } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatGameHoursSpan } from "../src/clock/timeFormat";
import { useClock } from "../src/clock/clock";
import { formatWindow } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -102,7 +102,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => {
const fast = option(await openSheet(), "Quick Finch");
expect(within(fast).getByRole("radio")).toBeEnabled();
expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent(
`Priority: ${formatWindow(trip("courier"))} trip, ${formatWindow(window - trip("courier"))} to spare in the ${formatWindow(window)} window`,
`Priority: ${formatGameHoursSpan(trip("courier"), "real")} trip, ${formatGameHoursSpan(window - trip("courier"), "real")} to spare in the ${formatGameHoursSpan(window, "real")} window`,
);
});
@ -112,7 +112,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => {
const slow = option(await openSheet(), "Old Barge");
expect(within(slow).getByRole("radio")).toBeDisabled();
expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent(
`Too slow — ${formatWindow(trip("bulk-hauler"))} vs ${formatWindow(window)} window`,
`Too slow — ${formatGameHoursSpan(trip("bulk-hauler"), "real")} vs ${formatGameHoursSpan(window, "real")} window`,
);
});
});

View File

@ -134,7 +134,7 @@ describe("dispatch sheet", () => {
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
expect(freighter).toHaveTextContent(
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config))}`,
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`,
);
expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`);
});

View File

@ -0,0 +1,112 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema";
import { MS_PER_HOUR, realToGame } from "@deadhead/sim";
import { act, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import {
formatGameHoursSpan,
formatGameSpan,
formatGameTime,
formatSpan,
formatTimeOfDay,
} from "../src/clock/timeFormat";
import { formatClock } from "../src/format";
import { mapMotionStyle } from "../src/map/layout";
import { mapStyle } from "../src/map/style";
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
/** 4m 16s of real time is 4.27 game hours at 60× compression. */
const SPAN = 4 * 60_000 + 16_000;
describe("time and span helpers (Step 10K.3)", () => {
it("formats game time in 24-hour and 12-hour clocks", () => {
const t = Date.parse("2100-03-14T13:05:00Z");
expect(formatGameTime(t, "24h")).toBe("2100-03-14 13:05");
expect(formatGameTime(t, "12h")).toMatch(/^2100-03-14 1:05\sPM$/);
});
it("formats a real time of day in each clock mode", () => {
const t = new Date(2100, 0, 1, 15, 7).getTime();
expect(formatTimeOfDay(t, "24h")).toBe("15:07");
expect(formatTimeOfDay(t, "12h")).toMatch(/^0?3:07\sPM$/);
expect(formatTimeOfDay(t, "locale")).toBe(
new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
);
});
it("formats spans as real time, game time or both", () => {
expect(formatSpan(SPAN, "real")).toBe("4m 16s");
expect(formatSpan(SPAN, "game")).toBe("4.3 game h");
expect(formatSpan(SPAN, "both")).toBe("4m 16s · 4.3 game h");
expect(formatSpan(SPAN, "both", formatClock)).toBe("04:16 · 4.3 game h");
expect(formatGameSpan(62.4)).toBe("62 game h");
expect(formatGameHoursSpan(5, "game")).toBe("5.0 game h");
expect(formatGameHoursSpan(5, "real")).toBe(
formatSpan((5 * MS_PER_HOUR) / content.config.timeCompression, "real"),
);
});
it("stills the map background with reduced motion", () => {
expect(mapMotionStyle(true).parallax).toBe(0);
expect(mapMotionStyle(true).backgroundZoomFollow).toBe(0);
expect(mapMotionStyle(false).parallax).toBe(mapStyle.parallax);
});
});
describe("display options in the app (Step 10K.3)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => {
vi.useRealTimers();
act(() => usePreferences.getState().set(DEFAULT_PREFERENCES));
});
it("switches the HQ clock to 24-hour", async () => {
act(() => usePreferences.getState().set({ clock: "12h" }));
renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/");
const game = realToGame(NOW, content.config);
expect(await screen.findByLabelText("Game clock")).toHaveTextContent(
formatGameTime(game, "12h"),
);
act(() => usePreferences.getState().set({ clock: "24h" }));
expect(screen.getByLabelText("Game clock")).toHaveTextContent(formatGameTime(game, "24h"));
});
it("shows contract expiry in both real and game time when asked", async () => {
const job: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 1234,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + SPAN,
firstContact: false,
};
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ tutorial: null }),
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
}),
"/contracts?location=earth",
);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
expect(card).toHaveTextContent("expires in 4m 16s");
act(() => usePreferences.getState().set({ countdown: "both" }));
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");
});
});

View File

@ -16,7 +16,8 @@ describe("app layout", () => {
expect(await screen.findByRole("banner")).toBeInTheDocument();
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
// Device-locale clock by default (CR-011): 24-hour or 12-hour.
/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}( [AP]M)?$/,
);
const nav = screen.getByRole("navigation", { name: "Main" });
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);

View File

@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createPreferencesStore,
DEFAULT_PREFERENCES,
PREFERENCES_KEY,
reducedMotionOn,
type PreferenceStorage,
} from "../src/state/preferences";
/** An in-memory storage, like localStorage. */
function memoryStorage(): PreferenceStorage & { data: Map<string, string> } {
const data = new Map<string, string>();
return {
data,
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
};
}
/** jsdom has no matchMedia: stand one in that answers the reduced-motion query. */
const mockOsReducedMotion = (reduce: boolean) =>
vi.stubGlobal("matchMedia", (query: string) => ({ matches: reduce, media: query }));
describe("device preferences (Step 10K.1)", () => {
afterEach(() => vi.unstubAllGlobals());
it("starts with the defaults: locale clock, real-time countdowns, the device's reduced motion", () => {
const store = createPreferencesStore(memoryStorage());
expect(store.getState()).toMatchObject(DEFAULT_PREFERENCES);
mockOsReducedMotion(true);
expect(reducedMotionOn(store.getState())).toBe(true);
mockOsReducedMotion(false);
expect(reducedMotionOn(store.getState())).toBe(false);
// An override wins over the device.
store.getState().set({ reducedMotion: true });
expect(reducedMotionOn(store.getState())).toBe(true);
});
it("keeps saved values across a reload", () => {
const storage = memoryStorage();
const first = createPreferencesStore(storage);
first.getState().set({ clock: "24h", countdown: "both" });
first.getState().dismissTip("tutorial-intro");
first.getState().set({ mapCamera: { zoom: 3, panX: 10, panY: -4 } });
const reloaded = createPreferencesStore(storage);
expect(reloaded.getState()).toMatchObject({
clock: "24h",
countdown: "both",
dismissedTips: ["tutorial-intro"],
mapCamera: { zoom: 3, panX: 10, panY: -4 },
});
});
it("ignores broken saved data", () => {
const storage = memoryStorage();
storage.setItem(PREFERENCES_KEY, "{not json");
expect(createPreferencesStore(storage).getState()).toMatchObject(DEFAULT_PREFERENCES);
storage.setItem(
PREFERENCES_KEY,
JSON.stringify({ clock: "sundial", mapCamera: { zoom: "x" } }),
);
expect(createPreferencesStore(storage).getState()).toMatchObject(DEFAULT_PREFERENCES);
});
it("works in memory when storage throws, without errors", () => {
const broken: PreferenceStorage = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
const store = createPreferencesStore(broken);
expect(store.getState()).toMatchObject(DEFAULT_PREFERENCES);
expect(() => store.getState().set({ clock: "12h" })).not.toThrow();
expect(store.getState().clock).toBe("12h");
expect(() => createPreferencesStore(null).getState().dismissTip("x")).not.toThrow();
});
});

View File

@ -0,0 +1,39 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const owner = () => samplePlayerState({ tutorial: null });
describe("settings screen (Step 10K.2)", () => {
afterEach(() => {
vi.unstubAllEnvs();
usePreferences.getState().set(DEFAULT_PREFERENCES);
});
it("opens from the header gear with every section", async () => {
renderApp(fakeApi({ getPlayerState: owner }), "/");
await userEvent.click(await screen.findByRole("link", { name: "Settings" }));
expect(await screen.findByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
for (const section of ["Company", "Display", "Help", "About", "Developer"]) {
expect(screen.getByRole("region", { name: section }), section).toBeInTheDocument();
}
});
it("leaves out the Developer section in a production build", async () => {
vi.stubEnv("PROD", true);
renderApp(fakeApi({ getPlayerState: owner }), "/settings");
await screen.findByRole("region", { name: "Display" });
expect(screen.queryByRole("region", { name: "Developer" })).not.toBeInTheDocument();
});
it("saves display choices to the device preferences", async () => {
renderApp(fakeApi({ getPlayerState: owner }), "/settings");
const display = await screen.findByRole("region", { name: "Display" });
await userEvent.click(within(display).getByRole("radio", { name: "24-hour" }));
await userEvent.click(within(display).getByRole("radio", { name: "Both" }));
expect(usePreferences.getState()).toMatchObject({ clock: "24h", countdown: "both" });
expect(within(display).getByRole("radio", { name: "24-hour" })).toBeChecked();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -465,6 +465,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer |
| `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount |
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
| `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped |
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |

View File

@ -0,0 +1,127 @@
# Change Request CR-011 — Settings Screen and Company Backup
> **Numbering note (added when implemented):** this request arrived titled "CR-008 — Settings Screen and Company Backup" for "Phase 10H", applying to "CR-001 through CR-007". CR-008 to CR-010 and Phases 10H–10J already existed, so it is stored as **CR-011** and done as **Phase 10K**: every "10H.n" below means **10K.n**. Its decisions 0.5.53–0.5.58 are recorded as **0.5.71 No accounts yet, 0.5.72 Backup code, 0.5.73 Several devices, 0.5.74 New company, 0.5.75 Company name, 0.5.76 Display preferences** (0.5.53–0.5.70 were in use). Decision 0.5.18 (testable clock, clock-offset endpoint) is the same number here. "CR-001 through CR-007" means the build as it stands (CR-001 to CR-010).
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-007 complete
**Do this:** as **Phase 10H**, after Phase 10G and before Phase 11
**Related docs:** `implementation-plan.md`, the earlier change requests, `DECISIONS.md`
---
## 1. Why
There is no place for player preferences, no way to start over (needed constantly for playtesting), and no protection against losing a company.
**Accounts: no sign-up.** Players keep starting instantly as a guest. But a guest company currently lives only in a session on one device: clearing browser data, reinstalling the PWA, or switching phones loses it for good, and the GDD promises web and mobile can share one company. So this change adds a lightweight **backup code** instead of accounts: the player can view a code and enter it on any device to restore their company. No email, no password, no third-party sign-in. Full account linking stays deferred.
## 2. What's on the screen
The screen opens from a **gear icon in the header**, not a new bottom-nav tab (the bottom nav is already full).
| Section | Items |
|---|---|
| Company | Company name (rename) · Back up company (show backup code) · Restore a company (enter code) · Start a new company |
| Display | Clock: 12-hour / 24-hour · Countdowns: real time / game time / both · Reduced motion (on/off) |
| Help | Reset map view · Show tips again |
| About | Game version and build · Copy debug info |
| Developer | Only in non-production builds: advance clock, add credits, finish all flights |
No Notifications section until push notifications exist. Don't add placeholder items.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass.
- **Display and Help settings are device preferences.** Store them on the device (local storage), not on the server. Wrap every storage read and write so the app works with defaults if storage is unavailable.
- **Company settings are server data** and go through the API with the usual validation and row locks (Step 6.14).
- Destructive actions (new company, restore over an existing company) always require explicit confirmation.
- Update `architecture.md` for schema or API changes and `progress.md` after every step.
- Stop for the user's review at the end of Phase 10H.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **0.5.53 No accounts yet.** Players remain guests. Account linking (email, Apple, Google) stays deferred (add to §0.3).
- **0.5.54 Backup code.** Each player can have one backup code: random, at least 80 bits of entropy, shown as groups of letters and digits that avoid look-alike characters (no 0/O, 1/I/L). The server stores only a hash. Generating a new code invalidates the old one. Entering a valid code on any device signs that device into the same player. Restore attempts are rate-limited per IP and per device.
- **0.5.55 Several devices.** More than one device may be signed into the same player at once; the server is authoritative, so this is safe.
- **0.5.56 New company.** Starting a new company deletes all of the player's company data and recreates the starting state exactly as guest creation does (CR-001 tutorial state, new world seed). The player record and backup code stay the same.
- **0.5.57 Company name.** Players can rename their company: 3–32 characters, letters, digits, spaces, and basic punctuation, trimmed. The default name is generated from the world seed.
- **0.5.58 Display preferences.** Clock defaults to the device's locale format. Countdowns default to "real time". Reduced motion defaults to the device's reduced-motion setting and can be overridden.
## 5. Steps — Phase 10H
### Step 10H.1 — Client: device preferences store
- Add a small preferences store for: clock format, countdown format, reduced motion, dismissed tips, and saved map camera. Defaults per decision 0.5.58. Persist to local storage, with every read and write wrapped so failures fall back to defaults silently.
- **Test:** With empty storage, defaults are returned (locale clock, real-time countdowns, reduced motion matching a mocked OS setting). Saved values survive a reload. With storage access mocked to throw, the store still works in memory and no error reaches the UI.
### Step 10H.2 — Client: settings screen and entry point
- Add a gear icon to the header that opens the Settings screen with the sections in §2. The Developer section renders only in non-production builds.
- **Test:** Component test: the gear opens Settings; all sections render; the Developer section is absent when the build mode is production.
### Step 10H.3 — Client: display options applied everywhere
- Route every clock display through one time-format helper that respects the clock preference.
- Route every countdown (arrivals, rest, shop time, board refresh, contract expiry, priority windows) through one countdown helper that shows real time, game time, or both ("4m 16s · 4.3 game h").
- Reduced motion turns off: map background parallax, animated screen transitions, and decorative pulses. Ships still move on the map, but without easing or trails.
- **Test:** Unit tests for both helpers in each mode. Component test: switching the clock to 24-hour changes the HQ clock; switching countdowns to "both" changes a contract card's expiry text. With reduced motion on, the map's parallax factor is zero.
### Step 10H.4 — Client: reset map view and show tips again
- "Reset map view" restores the default camera (centered, default zoom) and clears the saved camera.
- "Show tips again" clears the dismissed-tips list so one-time hints (for example the CR-001 intro card) can show again.
- **Test:** Component test: after panning and resetting, the camera matches the default. After clearing tips, a previously dismissed tip renders again.
### Step 10H.5 — Server: rename company
- Add a "rename company" endpoint per decision 0.5.57. Player state includes the company name.
- **Test:** A valid name is saved and trimmed. Names that are too short, too long, or contain disallowed characters are rejected with a validation error and nothing changes.
### Step 10H.6 — Server: start a new company
- Add a "new company" endpoint per decision 0.5.56, in one transaction with the company row lock. Delete ships, pilots, bays, discovered locations, boards, accepted contracts, dispatches, upgrades, goals, and loan state; then create the starting state with the same code path as guest creation.
- Scheduled arrival jobs for deleted dispatches must do nothing when they fire.
- **Test:** A mid-game player who starts a new company ends up with exactly the guest starting state (tutorial on, loaner and avatar at Earth, starting credits, Earth and Moon discovered) and a different world seed. Their old dispatches' arrival jobs run without error and change nothing. The player's backup code still restores this player.
### Step 10H.7 — Client: rename and new company flows
- Company name is editable in place with inline validation.
- "Start a new company" opens a confirmation that explains everything will be lost and requires typing the current company name to enable the button. After success, the app goes to the tutorial start (Contracts with the intro card).
- **Test:** Component test: the confirm button stays disabled until the exact company name is typed; confirming calls the endpoint and navigates to the tutorial start.
### Step 10H.8 — Server: backup code and restore
- Add endpoints: "get or create backup code" (returns the existing code only at creation time; afterwards the player can regenerate it, which shows the new code once), and "restore with code" (unauthenticated; on success, signs this device into the matching player).
- Store only a hash of the code. Rate-limit restore attempts.
- **Test:** A generated code restores the same player on a second session, and both sessions see the same state. Regenerating invalidates the old code. A wrong code fails with a generic error. Exceeding the attempt limit returns a rate-limit error. The database never contains the plain code.
### Step 10H.9 — Client: backup and restore flows
- "Back up company" shows the code in large, grouped text with a Copy button and the line "Keep this somewhere safe. Anyone with this code can open your company." If a code already exists, offer "Generate a new code" with a warning that the old one stops working.
- "Restore a company" appears in Settings and on the start screen (under "Start your company"). Entering a code over a company that has progress past the tutorial shows a warning: "This device's current company will be left behind. Back it up first if you want to keep it."
- After a successful restore, the app reloads player state and goes to HQ.
- **Test:** Component test: the code displays grouped and Copy writes it to the clipboard; the restore warning appears only when the current company is past the tutorial; a successful restore navigates to HQ; a failed restore shows the error without leaving the screen.
### Step 10H.10 — Client: About and debug info
- Show the game version and build (from the build), and a "Copy debug info" button that copies: version, build, player id, current server time, server clock offset, and current screen.
- **Test:** Component test: the version renders; Copy writes a text block containing all listed fields.
### Step 10H.11 — Client and server: developer tools (non-production only)
- Developer section actions: advance the clock by 10 minutes or 1 hour (using the existing test-only clock-offset endpoint, decision 0.5.18), add 10,000 credits, and finish all flights now (advances the clock to the latest arrival).
- Add a test-only "add credits" endpoint registered only in non-production mode, like the clock-offset endpoint.
- **Test:** In development mode, each action changes state as described. In production mode, the add-credits and clock-offset endpoints return not-found and the Developer section doesn't render.
### Step 10H.12 — Plan amendments and regression
- Apply the amendments in §6 to `implementation-plan.md`.
- Run the full suite.
- **Test:** CI is green. Manual check: back up a mid-game company, restore it in a private browser window and confirm the same state; then start a new company on one device and confirm the other device sees the new company after refresh.
## 6. Amendments to `implementation-plan.md`
| Step | Change |
|---|---|
| 0.3 | Add "account linking (email, Apple, Google sign-in)" to deferred. |
| 0.5 | Add decisions 0.5.53–0.5.58. |
| 4.2 | Route catalog gains: rename company, new company, get/regenerate backup code, restore with code, and the non-production add-credits endpoint. |
| 5.3 | Store the backup code hash and company name. |
| 8.1 | Header gains the Settings gear. |
| 8.4 | Start screen gains "Restore a company". |
| 11.2 | Settings that need the server are disabled while offline; display and help settings still work. |
| 12.x | End-to-end tests may use "Start a new company" and the developer tools to reset and fast-forward. |
## 7. Out of scope
Account sign-up or sign-in of any kind, push notification settings, sound and music settings (there is no audio yet), language selection, multiple company save slots, cloud export/import of save files, and anything else in `implementation-plan.md` §0.3.

View File

@ -36,7 +36,7 @@ This plan covers the **base game only**: the core loop of taking contracts, disp
### 0.3 Explicitly deferred (do NOT build)
Random events, reputation, fuel price variation, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, automation/standing routes, interstellar, push notifications, Capacitor builds, Hardened landing class, Venus/Io, Fragile/Urgent/Fuel-efficiency skills, social features, monetization.
Random events, reputation, fuel price variation, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, automation/standing routes, interstellar, push notifications, Capacitor builds, Hardened landing class, Venus/Io, Fragile/Urgent/Fuel-efficiency skills, social features, monetization, account linking (email, Apple, Google sign-in) *(CR-011)*.
### 0.4 Glossary
@ -126,6 +126,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
68. **Courier to Mars** (CR-009). A Courier with maxed fuel tanks can reach Mars at and around closest approach, but not at Mars's farthest distance. Base and lower-level Couriers still can't reach Mars at all.
69. **Deduction order** (CR-010). On resolution: pilot pay is taken from the payout first; loan repayment (0.5.32) is calculated on what remains.
70. **Veteran pilot** (CR-010). Accepting the deal also creates the veteran (CR-010 §2): at Earth, unassigned, level 2 with Heavy Cargo 1. The veteran is a normal hired pilot in every respect except how they were acquired.
71. **No accounts yet** (CR-011). Players remain guests. Account linking (email, Apple, Google) stays deferred (§0.3).
72. **Backup code** (CR-011). Each player can have one backup code: random, at least 80 bits of entropy, shown as groups of letters and digits that avoid look-alike characters (no 0/O, 1/I/L). The server stores only a hash. Generating a new code invalidates the old one. Entering a valid code on any device signs that device into the same player. Restore attempts are rate-limited per IP and per device.
73. **Several devices** (CR-011). More than one device may be signed into the same player at once; the server is authoritative, so this is safe.
74. **New company** (CR-011). Starting a new company deletes all of the player's company data and recreates the starting state exactly as guest creation does (CR-001 tutorial state, new world seed). The player record and backup code stay the same.
75. **Company name** (CR-011). Players can rename their company: 3–32 characters, letters, digits, spaces, and basic punctuation, trimmed. The default name is generated from the world seed.
76. **Display preferences** (CR-011). Clock defaults to the device's locale format. Countdowns default to "real time". Reduced motion defaults to the device's reduced-motion setting and can be overridden.
---
@ -604,6 +610,12 @@ Steps 10J.1–10J.7 are specified in `memory-bank/change-requests/CR-010-veteran
---
## Phase 10K — Settings Screen and Company Backup (CR-011)
Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settings-backup.md` §5 (written there as 10H.1–10H.12), with its §6 amendments. Done after Phase 10J and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA

View File

@ -247,3 +247,20 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv
- [x] **Step 10J.5 — Client: deal modal and pilot card.** The deal modal says Halvorsen's pilot comes with the business and lists "Pilot: …and Rosa Kell. Halvorsen's pilot for twelve years." (from content). The veteran's Pilots card shows the bio line under the name. Test: `veteran-ui.test.tsx`.
- [x] **Step 10J.6 — Client: pilot share in dispatch and summaries.** The dispatch sheet shows "Pilot share" between Fuel and Net profit for hired pilots (sim `pilotShare`), none for the avatar; net profit is payout − fuel − share. API `ResolutionSummary.pilotPay` (from the stored resolution); the away summary shows "[share] pilot share to [pilot]" when above zero. Test: `pilot-share.test.tsx`.
- [x] **Step 10J.7 — Plan amendments and regression.** CR-010 §6 amendments: plan steps 2.1, 3.17, 9.5 and 12.1 (the 30–45 minute target is now the second ship), and CR-001 Steps 10A.5, 10A.8, 10A.13 and its §2 targets (decisions 0.5.12, 0.5.69–70 were set with the CR). Typecheck, lint, full suite with coverage (828 tests) and the web build are green. Scripted manual check against a live server (scratchpad `cr010-smoke.mjs`): after the deal Rosa Kell is at Earth, unassigned, level 2, Heavy Cargo 1; the avatar rests at Earth after six trips; swapped crews; Rosa flies a 20 t Machine Parts job needing Heavy Cargo 1 (the avatar has 0): payout 1118, fuel 230, pilot share 222, loan repayment 268, credits 628; goal 4 ready. **Phase 10J complete — stopped for review.**
## Phase 10K — Settings Screen and Company Backup (CR-011)
Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-008 … Phase 10H"). New decisions recorded as plan §0.5.71–0.5.76.
- [x] **Step 10K.1 — Client: device preferences store.** `state/preferences.ts`: `usePreferences` (Zustand) with clock (`locale`/`12h`/`24h`), countdown (`real`/`game`/`both`), reduced-motion override (null = follow the device), dismissed tips and saved map camera; `set`, `dismissTip`, `showTipsAgain`. Saved under `deadhead:preferences:v1`; loading validates each field and falls back to defaults; every read and write is wrapped. `reducedMotionOn(prefs)`. Test: `preferences.test.ts` (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory).
- [x] **Step 10K.2 — Client: settings screen and entry point.** A gear link (`aria-label="Settings"`, new `gear` icon) at the right of the header opens `/settings` (a route inside the layout, not a nav tab). `screens/Settings.tsx`: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when `import.meta.env.PROD` is false. Remaining items arrive with their steps. Test: `settings.test.tsx`.
- [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets `<html data-reduced-motion>`, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults.
- [ ] Step 10K.4 — Client: reset map view and show tips again
- [ ] Step 10K.5 — Server: rename company
- [ ] Step 10K.6 — Server: start a new company
- [ ] Step 10K.7 — Client: rename and new company flows
- [ ] Step 10K.8 — Server: backup code and restore
- [ ] Step 10K.9 — Client: backup and restore flows
- [ ] Step 10K.10 — Client: About and debug info
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
- [ ] Step 10K.12 — Plan amendments and regression