ugly-game 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,12 @@ export interface ControlView<S extends string> {
30
30
  raw(): Int32Array;
31
31
  /** Atomically read-and-zero STEP_BUDGET — the worker consumes the burst so it can't be applied twice. */
32
32
  takeStepBudget(): number;
33
+ /** Atomically add to the LATE counter — the worker flags each command that arrived after its tick (co-op
34
+ * divergence signal). Add (not store) so concurrent late flags in one batch accumulate. */
35
+ addLate(n: number): void;
36
+ /** Atomically read-and-zero LATE — the main thread drains the divergence count each frame without racing the
37
+ * worker's adds (an `exchange`, so an add landing between read and reset is never lost). */
38
+ takeLate(): number;
33
39
  /** Spin-wait (bounded) until PAUSED reaches `target`; returns true if it did. Main-thread safe (no Atomics.wait). */
34
40
  waitPaused(target: boolean, spinMax?: number): boolean;
35
41
  notify(slot: number): void;
@@ -104,6 +104,13 @@ export function createControlView(buf, scalarNames) {
104
104
  takeStepBudget() {
105
105
  return Atomics.exchange(a, H.STEP_BUDGET, 0);
106
106
  },
107
+ addLate(n) {
108
+ if (n)
109
+ Atomics.add(a, H.LATE, n | 0);
110
+ },
111
+ takeLate() {
112
+ return Atomics.exchange(a, H.LATE, 0);
113
+ },
107
114
  scalar(name) {
108
115
  const slot = idx.get(name);
109
116
  if (slot === undefined) {
@@ -21,6 +21,10 @@ export interface CoopStepOpts<S, I> {
21
21
  tickOf: (s: S) => number;
22
22
  maxCatchUpFrames?: number;
23
23
  onLate?: (count: number) => void;
24
+ /** Called once per tick, with the commands due THIS tick, immediately before `sim.tick`. The seam a game uses
25
+ * to drive per-tick co-op diagnostics (a command journal + a checkpoint hash trail) without the primitive
26
+ * needing to know their shape. `state` is passed so a checkpoint can hash it (at the top of the tick). */
27
+ beforeTick?: (due: I[], state: S) => void;
24
28
  }
25
29
  export interface SoloStepOpts<S, I> {
26
30
  sim: SimHostSim<S, I>;
@@ -34,6 +38,8 @@ export interface SoloStepOpts<S, I> {
34
38
  };
35
39
  fps?: number;
36
40
  maxCatchUpFrames?: number;
41
+ onLate?: (count: number) => void;
42
+ beforeTick?: (due: I[], state: S) => void;
37
43
  }
38
44
  /** Single-player stepping: derive a target tick from elapsed wall time (a free-run 60-UPS accumulator, clamped by
39
45
  * maxCatchUpFrames), publish it to `ctl.targetTick`, then reuse `coopStepBatch` — so solo and co-op run the identical
@@ -20,6 +20,8 @@ export function soloStepBatch(opts, acc) {
20
20
  queue: opts.queue,
21
21
  tickOf: opts.tickOf,
22
22
  maxCatchUpFrames: maxCatch,
23
+ ...(opts.onLate ? { onLate: opts.onLate } : {}),
24
+ ...(opts.beforeTick ? { beforeTick: opts.beforeTick } : {}),
23
25
  });
24
26
  if (stepped > 0)
25
27
  opts.ctl.ups = Math.round(stepped / Math.max(elapsed, T)) || opts.ctl.ups;
@@ -43,7 +45,12 @@ export function coopStepBatch(opts) {
43
45
  if (late && opts.onLate)
44
46
  opts.onLate(late);
45
47
  }
46
- opts.sim.tick(opts.state, due.map((d) => d.c));
48
+ const inputs = due.map((d) => d.c);
49
+ // Per-tick diagnostics seam: the game records the applied stream + a checkpoint hash of the top-of-tick state,
50
+ // BEFORE the tick mutates it. Fires every tick (solo + co-op) so a journal can't silently skip history.
51
+ if (opts.beforeTick)
52
+ opts.beforeTick(inputs, opts.state);
53
+ opts.sim.tick(opts.state, inputs);
47
54
  stepped++;
48
55
  };
49
56
  // STEP_BUDGET burst first (forced, deterministic — independent of the target).
@@ -3,6 +3,7 @@ import { type ControlView } from './controlBlock';
3
3
  import { type SimClock, type SimHostSim } from './simCore';
4
4
  import { type Lockstep, type LockstepTransport, type LockstepClock } from './lockstep';
5
5
  import type { NetAdapter } from './gameLoop';
6
+ import type { SimWorkerReply } from './simHost.worker';
6
7
  export interface WorkerLike {
7
8
  postMessage(m: unknown): void;
8
9
  onmessage: ((e: {
@@ -17,6 +18,7 @@ export interface CoopOpts {
17
18
  hashEvery?: number;
18
19
  onNeedSnap?: (from: string) => void;
19
20
  onSnapReady?: (from: string) => void;
21
+ onLate?: (n: number) => void;
20
22
  }
21
23
  export interface SimHostOpts<State, Input> {
22
24
  layout: Layout<State>;
@@ -35,6 +37,8 @@ export interface SimHostOpts<State, Input> {
35
37
  initParams?: Record<string, unknown>;
36
38
  }
37
39
  export interface SimHost<State, Input> {
40
+ /** The live sim state view. In the worker path it's a zero-copy SAB view the worker mutates; a world-swap
41
+ * (`reinit`) re-points it, so read it fresh each frame rather than capturing it once. */
38
42
  readonly state: State;
39
43
  readonly available: boolean;
40
44
  send(input: Input): void;
@@ -43,8 +47,22 @@ export interface SimHost<State, Input> {
43
47
  hash(): string;
44
48
  netAdapter(): NetAdapter;
45
49
  lockstep?: Lockstep<Input>;
50
+ /** Call once per rendered frame. Co-op: publishes the server-derived `targetTick`, emits the probe hash on the
51
+ * grid, and drains the LATE divergence count (→ `coop.onLate`). In the fallback (no-worker) path it also steps
52
+ * the sim to `nowMs`. A worker-backed solo host needs no per-frame call, but calling `frame` is harmless. */
53
+ frame(nowMs: number): void;
54
+ /** Swap the whole world (new game / load / resync) to `fill` over a fresh buffer of `layout`'s size. `resync`
55
+ * keeps in-flight future commands (drops applied history); otherwise the command stream restarts. */
56
+ reinit(layout: Layout<State>, fill: (s: State) => void, opts?: {
57
+ resync?: boolean;
58
+ initParams?: Record<string, unknown>;
59
+ }): void;
60
+ /** Round-trip a message to the worker and await its reply (echoing `rid`). Resolves `null` in the fallback path
61
+ * (no worker) or on timeout — the caller computes a degraded answer locally. */
62
+ request(msg: Record<string, unknown>): Promise<SimWorkerReply | null>;
46
63
  stop(): void;
47
- /** Fallback/test only: advance the main-thread sim to wall time `nowMs`. Undefined when a worker owns stepping. */
64
+ /** Fallback/test only: advance the main-thread sim to wall time `nowMs`. Undefined when a worker owns stepping.
65
+ * Retained for existing callers/tests; `frame` supersedes it. */
48
66
  pump?(nowMs: number): void;
49
67
  }
50
68
  export declare function createSimHost<State, Input>(opts: SimHostOpts<State, Input>): SimHost<State, Input>;
@@ -15,7 +15,7 @@ export function createSimHost(opts) {
15
15
  const ctlBuf = useWorker
16
16
  ? new SharedArrayBuffer(controlByteLength(scalars))
17
17
  : new ArrayBuffer(controlByteLength(scalars));
18
- const state = opts.layout.attach(dataBuf);
18
+ let state = opts.layout.attach(dataBuf); // `let`: a world-swap (reinit) re-points this at a fresh buffer's view
19
19
  const ctl = createControlView(ctlBuf, scalars);
20
20
  opts.seed?.(state);
21
21
  const clock = opts.clock ?? { now: () => Date.now() };
@@ -24,16 +24,28 @@ export function createSimHost(opts) {
24
24
  const sampled = new Map(); // our probe hash per grid tick (for divergence compare)
25
25
  let lastHash = opts.sim.hash(state);
26
26
  // ── Worker path ──────────────────────────────────────────────
27
+ // request()/reply round-trip: the game's worker `onMessage` answers a diagnostics request by posting a reply that
28
+ // echoes `rid`; we resolve the matching pending Promise here. Keyed on `rid` alone, so it's oblivious to payload.
29
+ let reqSeq = 0;
30
+ const reqWait = new Map();
27
31
  let worker = null;
28
32
  if (useWorker && opts.worker) {
29
33
  worker = opts.worker();
30
34
  worker.onmessage = (e) => {
31
35
  const m = e.data;
32
- if (m.t === 'hash' && typeof m.hash === 'string') {
33
- lastHash = m.hash;
34
- if (typeof m.tick === 'number')
35
- sampled.set(m.tick, m.hash);
36
+ if (typeof m.rid === 'number') {
37
+ const r = reqWait.get(m.rid);
38
+ if (r) {
39
+ reqWait.delete(m.rid);
40
+ r(m);
41
+ }
42
+ return;
36
43
  }
44
+ // The worker's posted hash is the FULL digest → keep host.hash() cheap. Deliberately NOT written into
45
+ // `sampled`: co-op compares the PROBE hash (sampled on the main thread in `drive`), and mixing the two oracles
46
+ // would report perpetual divergence.
47
+ if (m.t === 'hash' && typeof m.hash === 'string')
48
+ lastHash = m.hash;
37
49
  };
38
50
  worker.postMessage({
39
51
  t: 'init',
@@ -70,28 +82,36 @@ export function createSimHost(opts) {
70
82
  });
71
83
  ctl.coop = true;
72
84
  }
73
- // ── Fallback path (main-thread stepping) ─────────────────────
85
+ // ── Per-frame driver ─────────────────────────────────────────
86
+ // Co-op bookkeeping runs on the MAIN thread from the shared state, so the local sample and the value sent to
87
+ // peers are the SAME oracle (`probe`): the worker's own posted hash is the FULL digest, used only for host.hash()
88
+ // — mixing it into `sampled` would compare full-vs-probe and cry divergence forever. In the fallback path this
89
+ // same driver also steps the sim (there's no worker to do it).
74
90
  const lastRef = { t: clock.now() };
75
91
  let acc = 0;
76
92
  const hashEvery = opts.coop?.hashEvery ?? 600;
77
- const pump = (nowMs) => {
78
- const drivenClock = { now: () => nowMs };
93
+ const drive = (nowMs, doStep) => {
79
94
  if (lockstep) {
80
- const tk = opts.tickOf(state);
81
- if (tk % hashEvery === 0)
95
+ const tk = worker ? ctl.tick : opts.tickOf(state);
96
+ if (tk % hashEvery === 0 && !sampled.has(tk))
82
97
  sampled.set(tk, probe());
83
98
  ctl.targetTick = lockstep.targetTick();
84
99
  lockstep.onFrame(tk);
85
- coopStepBatch({
86
- sim: opts.sim,
87
- state,
88
- ctl,
89
- queue,
90
- tickOf: opts.tickOf,
91
- maxCatchUpFrames: opts.maxCatchUpFrames ?? 30,
92
- });
100
+ if (doStep)
101
+ coopStepBatch({
102
+ sim: opts.sim,
103
+ state,
104
+ ctl,
105
+ queue,
106
+ tickOf: opts.tickOf,
107
+ maxCatchUpFrames: opts.maxCatchUpFrames ?? 30,
108
+ });
109
+ const late = ctl.takeLate();
110
+ if (late > 0)
111
+ opts.coop?.onLate?.(late);
93
112
  }
94
- else {
113
+ else if (doStep) {
114
+ const drivenClock = { now: () => nowMs };
95
115
  acc = soloStepBatch({
96
116
  sim: opts.sim,
97
117
  state,
@@ -104,7 +124,11 @@ export function createSimHost(opts) {
104
124
  maxCatchUpFrames: opts.maxCatchUpFrames,
105
125
  }, acc);
106
126
  }
107
- lastHash = opts.sim.hash(state);
127
+ if (!worker)
128
+ lastHash = opts.sim.hash(state); // worker keeps lastHash fresh via its posted 'hash' messages
129
+ };
130
+ const frame = (nowMs) => {
131
+ drive(nowMs, !worker); // worker steps itself; fallback steps in-frame
108
132
  };
109
133
  const noop = () => undefined;
110
134
  const net = opts.net ?? {
@@ -113,7 +137,9 @@ export function createSimHost(opts) {
113
137
  resync: () => lockstep?.needSnap(),
114
138
  };
115
139
  return {
116
- state,
140
+ get state() {
141
+ return state;
142
+ },
117
143
  available: useWorker,
118
144
  send(input) {
119
145
  if (lockstep)
@@ -142,10 +168,51 @@ export function createSimHost(opts) {
142
168
  hash: () => lastHash,
143
169
  netAdapter: () => net,
144
170
  lockstep,
171
+ frame,
172
+ reinit(newLayout, fill, ro) {
173
+ const nBuf = useWorker
174
+ ? new SharedArrayBuffer(newLayout.byteLength)
175
+ : new ArrayBuffer(newLayout.byteLength);
176
+ state = newLayout.attach(nBuf); // fill on the main thread, then hand the ready buffer to the worker
177
+ fill(state);
178
+ lastHash = opts.sim.hash(state);
179
+ sampled.clear();
180
+ acc = 0;
181
+ lastRef.t = clock.now();
182
+ if (worker) {
183
+ worker.postMessage({
184
+ t: 'reinit',
185
+ dataBuf: nBuf,
186
+ coop: !!opts.coop,
187
+ resync: !!ro?.resync,
188
+ ...(ro?.initParams ?? {}),
189
+ });
190
+ }
191
+ else if (ro?.resync) {
192
+ queue.prune(opts.tickOf(state)); // keep in-flight future commands past the snapshot tick
193
+ }
194
+ else {
195
+ queue.clear();
196
+ }
197
+ },
198
+ request(msg) {
199
+ const w = worker;
200
+ if (!w)
201
+ return Promise.resolve(null);
202
+ const rid = ++reqSeq;
203
+ return new Promise((res) => {
204
+ reqWait.set(rid, res);
205
+ w.postMessage({ ...msg, rid });
206
+ setTimeout(() => {
207
+ if (reqWait.delete(rid))
208
+ res(null); // never hang a caller on a stuck worker
209
+ }, 4000);
210
+ });
211
+ },
145
212
  stop() {
146
213
  ctl.running = false;
147
214
  worker?.terminate();
148
215
  },
149
- pump: useWorker ? undefined : pump,
216
+ pump: useWorker ? undefined : frame,
150
217
  };
151
218
  }
@@ -1,10 +1,23 @@
1
1
  import type { Layout } from './layout';
2
2
  import { type ControlView } from './controlBlock';
3
3
  import { type SimHostSim } from './simCore';
4
+ /** A reply a worker posts back to the main thread. Must be structured-cloneable. Echo the originating request's
5
+ * `rid` so the main thread's `host.request()` Promise resolves on it; any other fields are the game's payload. */
6
+ export interface SimWorkerReply {
7
+ rid?: number;
8
+ [k: string]: unknown;
9
+ }
10
+ export interface SimWorkerContext<S> {
11
+ state: S;
12
+ ctl: ControlView<string>;
13
+ post(m: SimWorkerReply): void;
14
+ }
4
15
  export interface SimWorkerConfig<S, I> {
5
16
  sim: SimHostSim<S, I>;
6
17
  layout: Layout<S> | ((init: Record<string, unknown>) => Layout<S>);
7
18
  tickOf: (s: S) => number;
8
19
  mirror?: (s: S, ctl: ControlView<string>) => void;
20
+ beforeTick?: (due: I[], state: S) => void;
21
+ onMessage?: (m: Record<string, unknown>, ctx: SimWorkerContext<S>) => void;
9
22
  }
10
23
  export declare function runSimWorker<S, I>(cfg: SimWorkerConfig<S, I>): void;
@@ -14,6 +14,12 @@ export function runSimWorker(cfg) {
14
14
  const loop = () => {
15
15
  if (state && ctl?.running) {
16
16
  const before = cfg.tickOf(state);
17
+ // Auto-wire late-arrival counting into the control block so the main thread's `takeLate()` always reflects
18
+ // divergence — the game needs no code for this (it's the same signal on every SimHost game).
19
+ const c = ctl;
20
+ const onLate = (n) => {
21
+ c.addLate(n);
22
+ };
17
23
  if (coop) {
18
24
  coopStepBatch({
19
25
  sim: cfg.sim,
@@ -22,6 +28,8 @@ export function runSimWorker(cfg) {
22
28
  queue,
23
29
  tickOf: cfg.tickOf,
24
30
  maxCatchUpFrames: 30,
31
+ onLate,
32
+ ...(cfg.beforeTick ? { beforeTick: cfg.beforeTick } : {}),
25
33
  });
26
34
  }
27
35
  else {
@@ -34,6 +42,8 @@ export function runSimWorker(cfg) {
34
42
  clock,
35
43
  lastRef,
36
44
  fps,
45
+ onLate,
46
+ ...(cfg.beforeTick ? { beforeTick: cfg.beforeTick } : {}),
37
47
  }, acc);
38
48
  }
39
49
  const stepped = cfg.tickOf(state) - before;
@@ -66,21 +76,45 @@ export function runSimWorker(cfg) {
66
76
  ctx.onmessage = (e) => {
67
77
  const m = e.data;
68
78
  if (m.t === 'init') {
69
- const layout = typeof cfg.layout === 'function' ? cfg.layout(m) : cfg.layout;
70
- state = layout.attach(m.dataBuf);
71
- ctl = createControlView(m.ctlBuf, m.scalars ?? []);
72
- fps = m.simFps ?? 60;
73
- coop = !!m.coop;
79
+ const init = m;
80
+ const layout = typeof cfg.layout === 'function' ? cfg.layout(init) : cfg.layout;
81
+ state = layout.attach(init.dataBuf);
82
+ ctl = createControlView(init.ctlBuf, init.scalars ?? []);
83
+ fps = init.simFps ?? 60;
84
+ coop = !!init.coop;
74
85
  ctl.running = true;
75
86
  lastRef.t = clock.now();
76
87
  loop();
77
88
  }
89
+ else if (m.t === 'reinit') {
90
+ // World-swap (new game / load / resync): re-attach to the fresh, already-filled buffer (ctl + coop flag
91
+ // persist). A resync keeps in-flight future commands (drops applied history); otherwise the stream restarts.
92
+ const init = m;
93
+ const layout = typeof cfg.layout === 'function' ? cfg.layout(init) : cfg.layout;
94
+ state = layout.attach(init.dataBuf);
95
+ if (init.resync)
96
+ queue.prune(cfg.tickOf(state));
97
+ else
98
+ queue.clear();
99
+ lastRef.t = clock.now();
100
+ acc = 0;
101
+ }
78
102
  else if (m.t === 'cmd') {
79
103
  queue.push(m.s);
80
104
  }
81
- else {
82
- // resync-req: keep future commands, drop the applied history (an authoritative snapshot is being loaded).
105
+ else if (m.t === 'resync-req') {
106
+ // keep future commands, drop the applied history (an authoritative snapshot is being loaded).
83
107
  queue.prune(state ? cfg.tickOf(state) : 0);
84
108
  }
109
+ else if (state && ctl) {
110
+ // A message this runner doesn't own — hand it to the game (diagnostics requests, etc.).
111
+ cfg.onMessage?.(m, {
112
+ state,
113
+ ctl,
114
+ post: (msg) => {
115
+ ctx.postMessage(msg);
116
+ },
117
+ });
118
+ }
85
119
  };
86
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {