Step 14.1: engine package and device clock
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 19:11:20 -04:00
parent 974597b602
commit 6cdffa2a43
9 changed files with 145 additions and 1 deletions

View File

@ -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.

View File

@ -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 |

View File

@ -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

View File

@ -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"
}
}

View File

@ -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;
}
}

View File

@ -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";

View File

@ -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);
});
});

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}

View File

@ -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':