Step 8.3: add server-synced clock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5558296b77
commit
ba25066105
|
|
@ -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).
|
||||
- **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.
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { GameClock } from "../clock/GameClock";
|
||||
import { SCREENS } from "../routes";
|
||||
|
||||
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;
|
||||
clock?: ReactNode;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.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.5 — Player state store
|
||||
|
|
|
|||
Loading…
Reference in New Issue