diff --git a/DECISIONS.md b/DECISIONS.md index 6dfc6ff..57cd3b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1340,3 +1340,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Decisions:** its 0.5.66–0.5.71 are **0.5.87–0.5.92**. It replaces 0.5.18 and 0.5.20 (same numbers here) and its "0.5.53–0.5.55" are this plan's **0.5.71–0.5.73** (CR-011's accounts, backup code, several devices). Its "CR-008" is this repo's **CR-011**; its "CR-001 to CR-010" means **CR-001 to CR-013**. - **Order:** the "Fixed Map and Fuel Tanks" request arrived first (also titled CR-014) but is written for this local-first engine; the user chose to build local-first first. It will be recorded as **CR-015** (Phase 15). - **`tech-stack.md`** wasn't yet marked for this change in this repo (the CR says the designer had marked it); it is updated during the phase. + +## Step 14.1 + +- **The device clock is a small class with injected device time**, so tests set the device clock forward and back. It remembers the latest time it handed out; the save stores that (`lastTimeSeen`) and `restore` applies it on load, so restarting the app with the device clock set back still can't rewind the game. +- **The developer offset only moves forward** (`advance`), like the old developer route; there is no "set offset" any more, because moving back would be clamped anyway. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 30b3fd6..7ed0ec0 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -8,6 +8,7 @@ Living reference for how the code is organised. Update it after any database sch |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | | `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine | +| `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod | | `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema | | `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e1287d7..de6d8b6 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -341,7 +341,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 - **Phase 11** (installable PWA, offline shell: the offline banner and disabled actions are removed by this CR) and **Phase 12** (balance simulation with economic distance and fleet-fit first contacts; end-to-end tests, which move to the web build alone) complete. - **Follow-ups to preserve:** Earth launch cost 2.1; base pay +50 %; developer tools for pilots, ships and discovery (become developer-only engine commands); deadhead.belowland.com as an allowed Vite host. - **Not started:** the Fixed Map and Fuel Tanks request (recorded after this phase as CR-015). -- [ ] Step 14.1 — Engine package and clock +- [x] **Step 14.1 — Engine package and clock.** New `packages/engine` (depends on content, schema, sim; picked up as a Vitest project). `Clock` interface (`now()`) and `DeviceClock`: device time (injected, `Date.now` by default) plus a developer offset, clamped to the last time it handed out; `lastTimeSeen` for the save, `restore(lastTimeSeen)` after loading one, `advance(ms)` for the developer tools (forward only). Tests (`packages/engine/test/clock.test.ts`): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward. - [ ] Step 14.2 — Save document schema and migrations - [ ] Step 14.3 — Storage layer - [ ] Step 14.4 — Mutation queue and tab lock diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..5824abb --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,17 @@ +{ + "name": "@deadhead/engine", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@deadhead/content": "workspace:*", + "@deadhead/schema": "workspace:*", + "@deadhead/sim": "workspace:*", + "zod": "^4.6.5" + } +} diff --git a/packages/engine/src/clock.ts b/packages/engine/src/clock.ts new file mode 100644 index 0000000..8478b4c --- /dev/null +++ b/packages/engine/src/clock.ts @@ -0,0 +1,50 @@ +import type { RealTime } from "@deadhead/sim"; + +/** The engine's only source of time (CR-014): injected, so tests use a fixed one. */ +export interface Clock { + now(): RealTime; +} + +/** + * The device clock (decision 0.5.88): device time plus a dev-only offset, + * clamped so it never runs backward. Setting the device clock back leaves + * the game at the last time it saw; setting it forward is accepted. + */ +export class DeviceClock implements Clock { + #lastSeen: RealTime; + #offsetMs: number; + + constructor( + /** Device time; Date.now outside tests. */ + private readonly deviceNow: () => RealTime = () => Date.now(), + options: { lastTimeSeen?: RealTime; offsetMs?: number } = {}, + ) { + this.#lastSeen = options.lastTimeSeen ?? 0; + this.#offsetMs = options.offsetMs ?? 0; + } + + now(): RealTime { + this.#lastSeen = Math.max(this.deviceNow() + this.#offsetMs, this.#lastSeen); + return this.#lastSeen; + } + + /** The latest time handed out, stored in the save (CR-014 §2). */ + get lastTimeSeen(): RealTime { + return this.#lastSeen; + } + + /** After loading a save: never hand out a time before the one it last saw. */ + restore(lastTimeSeen: RealTime): void { + this.#lastSeen = Math.max(this.#lastSeen, lastTimeSeen); + } + + get offsetMs(): number { + return this.#offsetMs; + } + + /** Developer tool: move the game forward (never back). */ + advance(ms: number): void { + if (ms < 0) throw new RangeError("The clock only moves forward"); + this.#offsetMs += ms; + } +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 0000000..0432901 --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,3 @@ +// The game engine (CR-014): the game's commands running on the device, using +// storage, the clock and notifications only through injected interfaces. +export { DeviceClock, type Clock } from "./clock"; diff --git a/packages/engine/test/clock.test.ts b/packages/engine/test/clock.test.ts new file mode 100644 index 0000000..837baac --- /dev/null +++ b/packages/engine/test/clock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { DeviceClock } from "../src/index"; + +/** A device clock the test can set, forward or back. */ +function device(start: number) { + let time = start; + return { now: () => time, set: (t: number) => (time = t) }; +} + +describe("device clock (Step 14.1, decision 0.5.88)", () => { + it("returns device time plus the developer offset", () => { + const d = device(1_000_000); + const clock = new DeviceClock(d.now, { offsetMs: 5_000 }); + expect(clock.now()).toBe(1_005_000); + clock.advance(60_000); + expect(clock.now()).toBe(1_065_000); + }); + + it("never runs backward when the device clock is set back", () => { + const d = device(2_000_000); + const clock = new DeviceClock(d.now); + expect(clock.now()).toBe(2_000_000); + d.set(1_500_000); + expect(clock.now()).toBe(2_000_000); + expect(clock.lastTimeSeen).toBe(2_000_000); + }); + + it("reflects a forward move at once", () => { + const d = device(2_000_000); + const clock = new DeviceClock(d.now); + clock.now(); + d.set(9_000_000); + expect(clock.now()).toBe(9_000_000); + }); + + it("starts from a save's last time seen when the device is behind it", () => { + const d = device(1_000); + const clock = new DeviceClock(d.now, { lastTimeSeen: 5_000 }); + expect(clock.now()).toBe(5_000); + clock.restore(4_000); + expect(clock.now()).toBe(5_000); + clock.restore(7_000); + expect(clock.now()).toBe(7_000); + }); + + it("refuses to move backward through the developer offset", () => { + expect(() => new DeviceClock(() => 0).advance(-1)).toThrow(RangeError); + }); +}); diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57fa232..7a0ffaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,21 @@ importers: specifier: ^4.6.5 version: 4.6.5 + packages/engine: + dependencies: + '@deadhead/content': + specifier: workspace:* + version: link:../content + '@deadhead/schema': + specifier: workspace:* + version: link:../schema + '@deadhead/sim': + specifier: workspace:* + version: link:../sim + zod: + specifier: ^4.6.5 + version: 4.6.5 + packages/schema: dependencies: '@deadhead/sim':