Step 14.1: engine package and device clock
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
974597b602
commit
6cdffa2a43
|
|
@ -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**.
|
- **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).
|
- **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.
|
- **`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.
|
||||||
|
|
|
||||||
|
|
@ -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/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/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 |
|
| `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/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 |
|
| `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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).
|
- **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.2 — Save document schema and migrations
|
||||||
- [ ] Step 14.3 — Storage layer
|
- [ ] Step 14.3 — Storage layer
|
||||||
- [ ] Step 14.4 — Mutation queue and tab lock
|
- [ ] Step 14.4 — Mutation queue and tab lock
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
|
|
@ -167,6 +167,21 @@ importers:
|
||||||
specifier: ^4.6.5
|
specifier: ^4.6.5
|
||||||
version: 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:
|
packages/schema:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@deadhead/sim':
|
'@deadhead/sim':
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue