Step 8.3: add server-synced clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:52:42 -04:00
parent 5558296b77
commit ba25066105
6 changed files with 130 additions and 3 deletions

View File

@ -375,3 +375,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20). - **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20).
- **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format). - **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format).
- **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters. - **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters.
## Step 8.3
- **The clock offset lives in a Zustand store** (`useClock`), as the tech stack prescribes for real-time client state. `serverNow()` is `Date.now() + offset`; the device clock only affects visuals and is never trusted for outcomes.
- **Sync uses the midpoint of the round trip:** offset = server time − (sent + received) / 2.
- **A one-second ticker** (`startClockTicker`) refreshes `now` in the store so components re-render; components read `useClock(s => s.now)`.
- **The header shows in-game time** as `YYYY-MM-DD HH:MM` (UTC), from sim `realToGame`.

View File

@ -0,0 +1,14 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { useClock } from "./clock";
/** "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", " ");
}
/** The ticking in-game clock shown in the header. */
export function GameClock() {
const now = useClock((state) => state.now);
return <time>{formatGameTime(realToGame(now, content.config))}</time>;
}

View File

@ -0,0 +1,42 @@
import type { RealTime } from "@deadhead/sim";
import { create } from "zustand";
import type { ApiClient } from "../api/client";
interface ClockState {
/** Server time minus device time. A wrong device clock only shifts visuals. */
offsetMs: number;
synced: boolean;
/** Server-adjusted time, refreshed every second by the ticker. */
now: RealTime;
}
export const useClock = create<ClockState>(() => ({
offsetMs: 0,
synced: false,
now: Date.now(),
}));
/** The current time on the server's clock: device time plus the synced offset. */
export function serverNow(): RealTime {
return Date.now() + useClock.getState().offsetMs;
}
/**
* Fetches server time and stores the offset. The server's reading is assumed
* to be taken halfway through the round trip.
*/
export async function syncClock(api: ApiClient): Promise<void> {
const sentAt = Date.now();
const { realTime } = await api.call("getServerTime", {});
const receivedAt = Date.now();
const offsetMs = realTime - (sentAt + receivedAt) / 2;
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
}
/** Updates `now` every second. Returns a function that stops the ticker. */
export function startClockTicker(intervalMs = 1000): () => void {
const tick = () => useClock.setState({ now: serverNow() });
tick();
const id = setInterval(tick, intervalMs);
return () => clearInterval(id);
}

View File

@ -1,15 +1,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router"; import { NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS } from "../routes"; import { SCREENS } from "../routes";
export interface LayoutProps { export interface LayoutProps {
/** Shown at the top right; live values arrive with the player state and clock steps. */ /** Shown at the top right. Credits arrive with the player state (Step 8.5). */
credits?: ReactNode; credits?: ReactNode;
clock?: ReactNode; clock?: ReactNode;
} }
/** Header with credits and game clock, the current screen, and a bottom tab bar. */ /** Header with credits and game clock, the current screen, and a bottom tab bar. */
export function Layout({ credits = "—", clock = "—" }: LayoutProps) { export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps) {
return ( return (
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100"> <div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2"> <header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">

View File

@ -0,0 +1,63 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { act, render, screen } 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";
import { formatGameTime, GameClock } from "../src/clock/GameClock";
const DEVICE_NOW = Date.parse("2100-01-01T00:00:00Z");
const TEN_MINUTES = 10 * 60_000;
/** An API whose server clock runs `aheadMs` ahead of the device. */
function serverAhead(aheadMs: number): ApiClient {
return {
call: vi.fn(async () => ({ realTime: Date.now() + aheadMs, gameTime: 0 })),
} as unknown as ApiClient;
}
describe("server-synced clock", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(DEVICE_NOW);
useClock.setState({ offsetMs: 0, synced: false, now: DEVICE_NOW });
});
afterEach(() => vi.useRealTimers());
it("returns a time 10 minutes ahead when the server is 10 minutes ahead", async () => {
await syncClock(serverAhead(TEN_MINUTES));
expect(useClock.getState().synced).toBe(true);
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES);
vi.advanceTimersByTime(5000);
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES + 5000);
});
it("works when the server is behind the device", async () => {
await syncClock(serverAhead(-30_000));
expect(serverNow()).toBe(DEVICE_NOW - 30_000);
});
it("ticks the store every second", async () => {
await syncClock(serverAhead(TEN_MINUTES));
const stop = startClockTicker();
vi.advanceTimersByTime(3000);
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
stop();
vi.advanceTimersByTime(3000);
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
});
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();
// One real minute is one game hour.
act(() => vi.advanceTimersByTime(60_000));
expect(screen.getByText(at(DEVICE_NOW + 60_000))).toBeInTheDocument();
stop();
});
});

View File

@ -79,6 +79,6 @@
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user. - [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message. - [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message.
- [ ] Step 8.3 — Server-synced clock - [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time.
- [ ] Step 8.4 — Guest onboarding - [ ] Step 8.4 — Guest onboarding
- [ ] Step 8.5 — Player state store - [ ] Step 8.5 — Player state store