ugly-game 0.6.1 → 0.7.1

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.
package/dist/index.d.ts CHANGED
@@ -16,5 +16,7 @@ export * from './loop/gameLoop';
16
16
  export * from './loop/layout';
17
17
  export * from './loop/controlBlock';
18
18
  export * from './loop/deltaRing';
19
+ export * from './loop/commandQueue';
20
+ export * from './loop/lockstep';
19
21
  export * from './loop/simCore';
20
22
  export * from './loop/simHost';
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ export * from './loop/gameLoop';
18
18
  export * from './loop/layout';
19
19
  export * from './loop/controlBlock';
20
20
  export * from './loop/deltaRing';
21
+ export * from './loop/commandQueue';
22
+ export * from './loop/lockstep';
21
23
  export * from './loop/simCore';
22
24
  export * from './loop/simHost';
23
25
  // NOTE: './loop/simHost.worker' is intentionally NOT in the barrel — it is a worker entry, imported directly as
@@ -0,0 +1,16 @@
1
+ export interface Stamped<I> {
2
+ c: I;
3
+ at: number;
4
+ by?: string;
5
+ seq?: number;
6
+ }
7
+ export declare class CommandQueue<I> {
8
+ private q;
9
+ push(s: Stamped<I>): void;
10
+ /** Splice out and return every command with `at <= tick` (in order), INCLUDING late ones (`at < tick`). */
11
+ due(tick: number): Stamped<I>[];
12
+ /** Drop commands with `at <= tick`, keeping the future — used on resync after loading an authoritative snapshot. */
13
+ prune(tick: number): void;
14
+ clear(): void;
15
+ get size(): number;
16
+ }
@@ -0,0 +1,39 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // COMMAND QUEUE — the lockstep input buffer. In a co-op sim, inputs (commands) are stamped with the tick they must
3
+ // apply on (`at`) so every client applies the identical stream in the identical order. The worker drains everything
4
+ // `due` at each tick before advancing. Late arrivals (`at < tick` — the tick already stepped) are still returned so
5
+ // the caller can flag divergence. `prune` keeps the future (drops applied history) — the resync primitive; `clear`
6
+ // resets for a new game. Generalized from cogsworth's factoryCommand.ts, parameterized over the game's Input type.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ export class CommandQueue {
9
+ // Kept sorted ascending by (at, seq) so `due` is a cheap prefix splice.
10
+ q = [];
11
+ push(s) {
12
+ const key = (x) => x.seq ?? 0;
13
+ let i = this.q.length;
14
+ while (i > 0) {
15
+ const p = this.q[i - 1];
16
+ if (p.at < s.at || (p.at === s.at && key(p) <= key(s)))
17
+ break;
18
+ i--;
19
+ }
20
+ this.q.splice(i, 0, s);
21
+ }
22
+ /** Splice out and return every command with `at <= tick` (in order), INCLUDING late ones (`at < tick`). */
23
+ due(tick) {
24
+ let i = 0;
25
+ while (i < this.q.length && this.q[i].at <= tick)
26
+ i++;
27
+ return i === 0 ? [] : this.q.splice(0, i);
28
+ }
29
+ /** Drop commands with `at <= tick`, keeping the future — used on resync after loading an authoritative snapshot. */
30
+ prune(tick) {
31
+ this.q = this.q.filter((s) => s.at > tick);
32
+ }
33
+ clear() {
34
+ this.q.length = 0;
35
+ }
36
+ get size() {
37
+ return this.q.length;
38
+ }
39
+ }
@@ -9,6 +9,7 @@ export declare const CTL_HEADER: {
9
9
  readonly TARGET_TICK: 7;
10
10
  readonly COOP: 8;
11
11
  readonly LATE: 9;
12
+ readonly STEP_BUDGET: 10;
12
13
  };
13
14
  export declare function controlByteLength(scalarNames: readonly string[]): number;
14
15
  export type { ScalarCell } from './layout';
@@ -24,8 +25,11 @@ export interface ControlView<S extends string> {
24
25
  targetTick: number;
25
26
  coop: boolean;
26
27
  late: number;
28
+ stepBudget: number;
27
29
  scalar(name: S): ScalarCell;
28
30
  raw(): Int32Array;
31
+ /** Atomically read-and-zero STEP_BUDGET — the worker consumes the burst so it can't be applied twice. */
32
+ takeStepBudget(): number;
29
33
  /** Spin-wait (bounded) until PAUSED reaches `target`; returns true if it did. Main-thread safe (no Atomics.wait). */
30
34
  waitPaused(target: boolean, spinMax?: number): boolean;
31
35
  notify(slot: number): void;
@@ -15,8 +15,9 @@ export const CTL_HEADER = {
15
15
  TARGET_TICK: 7,
16
16
  COOP: 8,
17
17
  LATE: 9,
18
+ STEP_BUDGET: 10, // main writes a burst of N forced ticks; the worker takes-and-zeros it (deterministic advance)
18
19
  };
19
- const HEADER_LEN = 10;
20
+ const HEADER_LEN = 11;
20
21
  export function controlByteLength(scalarNames) {
21
22
  return (HEADER_LEN + scalarNames.length) * 4;
22
23
  }
@@ -94,6 +95,15 @@ export function createControlView(buf, scalarNames) {
94
95
  set late(v) {
95
96
  setn(H.LATE, v);
96
97
  },
98
+ get stepBudget() {
99
+ return n(H.STEP_BUDGET);
100
+ },
101
+ set stepBudget(v) {
102
+ setn(H.STEP_BUDGET, v);
103
+ },
104
+ takeStepBudget() {
105
+ return Atomics.exchange(a, H.STEP_BUDGET, 0);
106
+ },
97
107
  scalar(name) {
98
108
  const slot = idx.get(name);
99
109
  if (slot === undefined) {
@@ -0,0 +1,37 @@
1
+ import type { Stamped } from './commandQueue';
2
+ export interface LockstepMsg {
3
+ t: string;
4
+ [k: string]: unknown;
5
+ }
6
+ export interface LockstepTransport {
7
+ send(m: LockstepMsg): void;
8
+ onMessage(cb: (m: LockstepMsg) => void): void;
9
+ me(): string;
10
+ }
11
+ export interface LockstepClock {
12
+ targetTick(): number;
13
+ setEpoch(tick: number): void;
14
+ }
15
+ export interface LockstepHooks<I> {
16
+ onCommand(s: Stamped<I>): void;
17
+ probeHash(): string;
18
+ hashAt(tick: number): string | undefined;
19
+ onNeedSnap(from: string): void;
20
+ onSnapReady(from: string): void;
21
+ }
22
+ export interface Lockstep<I> {
23
+ send(cmd: I): void;
24
+ targetTick(): number;
25
+ onFrame(tick: number): void;
26
+ needSnap(from?: string): void;
27
+ snapReady(to: string): void;
28
+ repin(tick: number): void;
29
+ }
30
+ export interface LockstepOpts<I> {
31
+ transport: LockstepTransport;
32
+ clock: LockstepClock;
33
+ hooks: LockstepHooks<I>;
34
+ lead?: number;
35
+ hashEvery?: number;
36
+ }
37
+ export declare function createLockstep<I>(opts: LockstepOpts<I>): Lockstep<I>;
@@ -0,0 +1,63 @@
1
+ export function createLockstep(opts) {
2
+ const { transport, clock, hooks } = opts;
3
+ const lead = opts.lead ?? 20;
4
+ const hashEvery = opts.hashEvery ?? 600;
5
+ const me = transport.me();
6
+ const targetTick = () => clock.targetTick();
7
+ const send = (cmd) => {
8
+ transport.send({ t: 'cmd', cmd, at: targetTick() + lead, by: me });
9
+ };
10
+ const needSnap = (from) => {
11
+ transport.send({ t: 'need-snap', from: from ?? me });
12
+ };
13
+ const snapReady = (to) => {
14
+ transport.send({ t: 'snap-ready', from: me, to });
15
+ };
16
+ const repin = (tick) => {
17
+ clock.setEpoch(tick);
18
+ transport.send({ t: 'epoch', tick });
19
+ };
20
+ const onFrame = (tick) => {
21
+ if (hashEvery > 0 && tick % hashEvery === 0) {
22
+ transport.send({ t: 'hash', tick, hash: hooks.probeHash(), by: me });
23
+ }
24
+ };
25
+ transport.onMessage((m) => {
26
+ switch (m.t) {
27
+ case 'cmd':
28
+ hooks.onCommand({
29
+ c: m.cmd,
30
+ at: m.at,
31
+ by: m.by,
32
+ seq: m.seq,
33
+ });
34
+ break;
35
+ case 'hash': {
36
+ if (m.by === me)
37
+ break; // our own echo
38
+ const local = hooks.hashAt(m.tick);
39
+ if (local !== undefined && local !== m.hash) {
40
+ // Deterministic tiebreak: the higher peer-id yields and requests an authoritative snapshot (from = me, the
41
+ // loser). Total order on ids means exactly one side moves — no ping-pong.
42
+ if (me > m.by)
43
+ needSnap();
44
+ }
45
+ break;
46
+ }
47
+ case 'need-snap':
48
+ if (m.from !== me)
49
+ hooks.onNeedSnap(m.from);
50
+ break;
51
+ case 'snap-ready':
52
+ if (m.to === undefined || m.to === me)
53
+ hooks.onSnapReady(m.from);
54
+ break;
55
+ case 'epoch':
56
+ clock.setEpoch(m.tick);
57
+ break;
58
+ default:
59
+ break;
60
+ }
61
+ });
62
+ return { send, targetTick, onFrame, needSnap, snapReady, repin };
63
+ }
@@ -1,8 +1,50 @@
1
1
  import type { DeterministicSim } from '../moat';
2
2
  import type { ControlView } from './controlBlock';
3
+ import type { CommandQueue } from './commandQueue';
3
4
  export interface SimClock {
4
5
  now(): number;
5
6
  }
7
+ /** The lockstep-capable sim contract. `tick(state, due)` applies the inputs due THIS tick (in order) then advances
8
+ * exactly one tick — a superset of the single-player `step` (which fuses one input + one advance). `probeHash` is an
9
+ * optional fast dynamic-only hash for co-op divergence probes; `hash` is the full bit-sensitive digest. */
10
+ export interface SimHostSim<S, I> {
11
+ clone(s: S): S;
12
+ hash(s: S): string;
13
+ probeHash?(s: S): string;
14
+ tick(s: S, due: I[]): void;
15
+ }
16
+ export interface CoopStepOpts<S, I> {
17
+ sim: SimHostSim<S, I>;
18
+ state: S;
19
+ ctl: ControlView<string>;
20
+ queue: CommandQueue<I>;
21
+ tickOf: (s: S) => number;
22
+ maxCatchUpFrames?: number;
23
+ onLate?: (count: number) => void;
24
+ }
25
+ export interface SoloStepOpts<S, I> {
26
+ sim: SimHostSim<S, I>;
27
+ state: S;
28
+ ctl: ControlView<string>;
29
+ queue: CommandQueue<I>;
30
+ tickOf: (s: S) => number;
31
+ clock: SimClock;
32
+ lastRef: {
33
+ t: number;
34
+ };
35
+ fps?: number;
36
+ maxCatchUpFrames?: number;
37
+ }
38
+ /** Single-player stepping: derive a target tick from elapsed wall time (a free-run 60-UPS accumulator, clamped by
39
+ * maxCatchUpFrames), publish it to `ctl.targetTick`, then reuse `coopStepBatch` — so solo and co-op run the identical
40
+ * drain-due-then-tick path (the only difference is WHO sets the target: the accumulator vs the shared clock).
41
+ * Returns the leftover accumulator (seconds). */
42
+ export declare function soloStepBatch<S, I>(opts: SoloStepOpts<S, I>, acc: number): number;
43
+ /** Advance a lockstep sim toward the server-derived `ctl.targetTick`: for each tick, apply the commands due at that
44
+ * tick (flagging late arrivals), then `sim.tick`. A `ctl.stepBudget` burst forces extra ticks first (deterministic
45
+ * harness/test advance). Catch-up per call is clamped by `maxCatchUpFrames` so a large gap degrades UPS instead of
46
+ * blocking. Returns the number of ticks stepped. Pure w.r.t. the control block — the worker calls it each loop. */
47
+ export declare function coopStepBatch<S, I>(opts: CoopStepOpts<S, I>): number;
6
48
  export interface FixedStepOpts<S, I> {
7
49
  sim: DeterministicSim<S, I>;
8
50
  state: S;
@@ -1,3 +1,61 @@
1
+ /** Single-player stepping: derive a target tick from elapsed wall time (a free-run 60-UPS accumulator, clamped by
2
+ * maxCatchUpFrames), publish it to `ctl.targetTick`, then reuse `coopStepBatch` — so solo and co-op run the identical
3
+ * drain-due-then-tick path (the only difference is WHO sets the target: the accumulator vs the shared clock).
4
+ * Returns the leftover accumulator (seconds). */
5
+ export function soloStepBatch(opts, acc) {
6
+ const fps = opts.fps ?? 60;
7
+ const T = 1 / fps;
8
+ const maxCatch = opts.maxCatchUpFrames ?? 6;
9
+ const now = opts.clock.now();
10
+ const elapsed = Math.max(0, (now - opts.lastRef.t) / 1000);
11
+ opts.lastRef.t = now;
12
+ let a = Math.min(acc + elapsed, maxCatch * T);
13
+ const frames = Math.floor(a / T);
14
+ a -= frames * T;
15
+ opts.ctl.targetTick = opts.tickOf(opts.state) + frames;
16
+ const stepped = coopStepBatch({
17
+ sim: opts.sim,
18
+ state: opts.state,
19
+ ctl: opts.ctl,
20
+ queue: opts.queue,
21
+ tickOf: opts.tickOf,
22
+ maxCatchUpFrames: maxCatch,
23
+ });
24
+ if (stepped > 0)
25
+ opts.ctl.ups = Math.round(stepped / Math.max(elapsed, T)) || opts.ctl.ups;
26
+ return a;
27
+ }
28
+ /** Advance a lockstep sim toward the server-derived `ctl.targetTick`: for each tick, apply the commands due at that
29
+ * tick (flagging late arrivals), then `sim.tick`. A `ctl.stepBudget` burst forces extra ticks first (deterministic
30
+ * harness/test advance). Catch-up per call is clamped by `maxCatchUpFrames` so a large gap degrades UPS instead of
31
+ * blocking. Returns the number of ticks stepped. Pure w.r.t. the control block — the worker calls it each loop. */
32
+ export function coopStepBatch(opts) {
33
+ const maxCatch = opts.maxCatchUpFrames ?? 30;
34
+ let stepped = 0;
35
+ const stepOnce = () => {
36
+ const due = opts.queue.due(opts.tickOf(opts.state));
37
+ if (due.length) {
38
+ const now = opts.tickOf(opts.state);
39
+ let late = 0;
40
+ for (const s of due)
41
+ if (s.at < now)
42
+ late++;
43
+ if (late && opts.onLate)
44
+ opts.onLate(late);
45
+ }
46
+ opts.sim.tick(opts.state, due.map((d) => d.c));
47
+ stepped++;
48
+ };
49
+ // STEP_BUDGET burst first (forced, deterministic — independent of the target).
50
+ let budget = opts.ctl.takeStepBudget();
51
+ while (budget-- > 0 && stepped < maxCatch)
52
+ stepOnce();
53
+ // Then chase the target.
54
+ const target = opts.ctl.targetTick;
55
+ while (opts.tickOf(opts.state) < target && stepped < maxCatch)
56
+ stepOnce();
57
+ return stepped;
58
+ }
1
59
  export function fixedStepBatch(opts, acc) {
2
60
  const fps = opts.fps ?? 60;
3
61
  const maxCatch = (opts.maxCatchUpFrames ?? 6) / fps; // seconds
@@ -1,7 +1,7 @@
1
1
  import type { Layout } from './layout';
2
2
  import { type ControlView } from './controlBlock';
3
- import { type SimClock } from './simCore';
4
- import type { DeterministicSim } from '../moat';
3
+ import { type SimClock, type SimHostSim } from './simCore';
4
+ import { type Lockstep, type LockstepTransport, type LockstepClock } from './lockstep';
5
5
  import type { NetAdapter } from './gameLoop';
6
6
  export interface WorkerLike {
7
7
  postMessage(m: unknown): void;
@@ -10,30 +10,18 @@ export interface WorkerLike {
10
10
  }) => void) | null;
11
11
  terminate(): void;
12
12
  }
13
- export type HostToWorker<I> = {
14
- t: 'init';
15
- ctlBuf: ArrayBufferLike;
16
- dataBuf: ArrayBufferLike;
17
- scalars: readonly string[];
18
- simFps: number;
19
- } | {
20
- t: 'input';
21
- v: I;
22
- } | {
23
- t: 'resync-req';
24
- };
25
- export type WorkerToHost = {
26
- t: 'ready';
27
- } | {
28
- t: 'hash';
29
- hash: string;
30
- } | {
31
- t: 'cold';
32
- payload: unknown;
33
- };
13
+ export interface CoopOpts {
14
+ transport: LockstepTransport;
15
+ clock: LockstepClock;
16
+ lead?: number;
17
+ hashEvery?: number;
18
+ onNeedSnap?: (from: string) => void;
19
+ onSnapReady?: (from: string) => void;
20
+ }
34
21
  export interface SimHostOpts<State, Input> {
35
22
  layout: Layout<State>;
36
- sim: DeterministicSim<State, Input>;
23
+ sim: SimHostSim<State, Input>;
24
+ tickOf: (s: State) => number;
37
25
  seed?: (s: State) => void;
38
26
  scalars?: readonly string[];
39
27
  worker?: () => WorkerLike;
@@ -41,6 +29,10 @@ export interface SimHostOpts<State, Input> {
41
29
  simFps?: number;
42
30
  maxCatchUpFrames?: number;
43
31
  net?: NetAdapter;
32
+ coop?: CoopOpts;
33
+ /** Extra params forwarded verbatim in the worker `init` message — e.g. a map size a worker rebuilds its Layout
34
+ * from. Must be structured-cloneable. */
35
+ initParams?: Record<string, unknown>;
44
36
  }
45
37
  export interface SimHost<State, Input> {
46
38
  readonly state: State;
@@ -50,6 +42,7 @@ export interface SimHost<State, Input> {
50
42
  ctl: ControlView<string>;
51
43
  hash(): string;
52
44
  netAdapter(): NetAdapter;
45
+ lockstep?: Lockstep<Input>;
53
46
  stop(): void;
54
47
  /** Fallback/test only: advance the main-thread sim to wall time `nowMs`. Undefined when a worker owns stepping. */
55
48
  pump?(nowMs: number): void;
@@ -1,5 +1,7 @@
1
1
  import { createControlView, controlByteLength, } from './controlBlock';
2
- import { fixedStepBatch } from './simCore';
2
+ import { soloStepBatch, coopStepBatch, } from './simCore';
3
+ import { CommandQueue } from './commandQueue';
4
+ import { createLockstep, } from './lockstep';
3
5
  const canSAB = () => typeof SharedArrayBuffer !== 'undefined' &&
4
6
  (globalThis
5
7
  .crossOriginIsolated ??
@@ -16,8 +18,10 @@ export function createSimHost(opts) {
16
18
  const state = opts.layout.attach(dataBuf);
17
19
  const ctl = createControlView(ctlBuf, scalars);
18
20
  opts.seed?.(state);
19
- const inputQ = [];
20
21
  const clock = opts.clock ?? { now: () => Date.now() };
22
+ const queue = new CommandQueue();
23
+ const probe = () => opts.sim.probeHash ? opts.sim.probeHash(state) : opts.sim.hash(state);
24
+ const sampled = new Map(); // our probe hash per grid tick (for divergence compare)
21
25
  let lastHash = opts.sim.hash(state);
22
26
  // ── Worker path ──────────────────────────────────────────────
23
27
  let worker = null;
@@ -25,55 +29,97 @@ export function createSimHost(opts) {
25
29
  worker = opts.worker();
26
30
  worker.onmessage = (e) => {
27
31
  const m = e.data;
28
- if (m.t === 'hash')
32
+ if (m.t === 'hash' && typeof m.hash === 'string') {
29
33
  lastHash = m.hash;
34
+ if (typeof m.tick === 'number')
35
+ sampled.set(m.tick, m.hash);
36
+ }
30
37
  };
31
- const init = {
38
+ worker.postMessage({
32
39
  t: 'init',
33
40
  ctlBuf,
34
41
  dataBuf,
35
42
  scalars,
36
43
  simFps: opts.simFps ?? 60,
37
- };
38
- worker.postMessage(init);
44
+ coop: !!opts.coop,
45
+ ...opts.initParams,
46
+ });
39
47
  ctl.playing = true;
40
48
  }
49
+ // ── Lockstep driver (co-op) — lives on the main thread; feeds commands to the queue (fallback) or worker. ──
50
+ const enqueue = (s) => {
51
+ if (worker)
52
+ worker.postMessage({ t: 'cmd', s });
53
+ else
54
+ queue.push(s);
55
+ };
56
+ let lockstep;
57
+ if (opts.coop) {
58
+ lockstep = createLockstep({
59
+ transport: opts.coop.transport,
60
+ clock: opts.coop.clock,
61
+ lead: opts.coop.lead,
62
+ hashEvery: opts.coop.hashEvery,
63
+ hooks: {
64
+ onCommand: enqueue,
65
+ probeHash: probe,
66
+ hashAt: (t) => sampled.get(t),
67
+ onNeedSnap: opts.coop.onNeedSnap ?? (() => undefined),
68
+ onSnapReady: opts.coop.onSnapReady ?? (() => undefined),
69
+ },
70
+ });
71
+ ctl.coop = true;
72
+ }
41
73
  // ── Fallback path (main-thread stepping) ─────────────────────
42
74
  const lastRef = { t: clock.now() };
43
75
  let acc = 0;
76
+ const hashEvery = opts.coop?.hashEvery ?? 600;
44
77
  const pump = (nowMs) => {
45
- // The fallback clock is driven by the caller's timestamp for determinism in tests + frame-locked stepping.
46
78
  const drivenClock = { now: () => nowMs };
47
- acc = fixedStepBatch({
48
- sim: opts.sim,
49
- state,
50
- ctl,
51
- nextInput: () => inputQ.shift(),
52
- clock: drivenClock,
53
- lastRef,
54
- fps: opts.simFps,
55
- maxCatchUpFrames: opts.maxCatchUpFrames,
56
- }, acc);
79
+ if (lockstep) {
80
+ const tk = opts.tickOf(state);
81
+ if (tk % hashEvery === 0)
82
+ sampled.set(tk, probe());
83
+ ctl.targetTick = lockstep.targetTick();
84
+ 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
+ });
93
+ }
94
+ else {
95
+ acc = soloStepBatch({
96
+ sim: opts.sim,
97
+ state,
98
+ ctl,
99
+ queue,
100
+ tickOf: opts.tickOf,
101
+ clock: drivenClock,
102
+ lastRef,
103
+ fps: opts.simFps,
104
+ maxCatchUpFrames: opts.maxCatchUpFrames,
105
+ }, acc);
106
+ }
57
107
  lastHash = opts.sim.hash(state);
58
108
  };
59
- // Solo default: nothing to catch up or resync (single-player). A co-op game passes its own NetAdapter.
60
109
  const noop = () => undefined;
61
110
  const net = opts.net ?? {
62
- window: opts.maxCatchUpFrames ?? 6,
111
+ window: opts.maxCatchUpFrames ?? 30,
63
112
  catchUp: noop,
64
- resync: noop,
113
+ resync: () => lockstep?.needSnap(),
65
114
  };
66
115
  return {
67
116
  state,
68
117
  available: useWorker,
69
118
  send(input) {
70
- if (worker) {
71
- const msg = { t: 'input', v: input };
72
- worker.postMessage(msg);
73
- }
74
- else {
75
- inputQ.push(input);
76
- }
119
+ if (lockstep)
120
+ lockstep.send(input); // stamped + forwarded; comes back via onCommand
121
+ else
122
+ enqueue({ c: input, at: opts.tickOf(state) + 1 }); // solo: apply next tick
77
123
  },
78
124
  edit(fn) {
79
125
  if (worker) {
@@ -89,12 +135,13 @@ export function createSimHost(opts) {
89
135
  }
90
136
  }
91
137
  else {
92
- fn(state); // fallback: no concurrent stepping, apply directly
138
+ fn(state);
93
139
  }
94
140
  },
95
141
  ctl,
96
142
  hash: () => lastHash,
97
143
  netAdapter: () => net,
144
+ lockstep,
98
145
  stop() {
99
146
  ctl.running = false;
100
147
  worker?.terminate();
@@ -1,8 +1,10 @@
1
- import type { DeterministicSim } from '../moat';
2
1
  import type { Layout } from './layout';
2
+ import { type ControlView } from './controlBlock';
3
+ import { type SimHostSim } from './simCore';
3
4
  export interface SimWorkerConfig<S, I> {
4
- sim: DeterministicSim<S, I>;
5
- layout: Layout<S>;
6
- makeInput?: (raw: unknown) => I;
5
+ sim: SimHostSim<S, I>;
6
+ layout: Layout<S> | ((init: Record<string, unknown>) => Layout<S>);
7
+ tickOf: (s: S) => number;
8
+ mirror?: (s: S, ctl: ControlView<string>) => void;
7
9
  }
8
10
  export declare function runSimWorker<S, I>(cfg: SimWorkerConfig<S, I>): void;
@@ -1,55 +1,86 @@
1
1
  import { createControlView } from './controlBlock';
2
- import { fixedStepBatch } from './simCore';
2
+ import { soloStepBatch, coopStepBatch, } from './simCore';
3
+ import { CommandQueue } from './commandQueue';
3
4
  export function runSimWorker(cfg) {
4
5
  const ctx = globalThis;
5
- const inputQ = [];
6
+ const queue = new CommandQueue();
6
7
  let state = null;
7
8
  let ctl = null;
8
9
  let acc = 0;
10
+ let coop = false;
9
11
  const clock = { now: () => performance.now() };
10
12
  const lastRef = { t: 0 };
11
13
  let fps = 60;
12
- let upsCount = 0;
13
- let upsWinStart = 0;
14
14
  const loop = () => {
15
15
  if (state && ctl?.running) {
16
- const before = ctl.tick;
17
- acc = fixedStepBatch({
18
- sim: cfg.sim,
19
- state,
20
- ctl,
21
- nextInput: () => inputQ.shift(),
22
- clock,
23
- lastRef,
24
- fps,
25
- }, acc);
26
- const stepped = ctl.tick - before;
27
- upsCount += stepped;
28
- const now = clock.now();
29
- if (upsWinStart === 0)
30
- upsWinStart = now;
31
- else if (now - upsWinStart >= 500) {
32
- ctl.ups = Math.round((upsCount * 1000) / (now - upsWinStart));
33
- upsCount = 0;
34
- upsWinStart = now;
16
+ const before = cfg.tickOf(state);
17
+ if (coop) {
18
+ coopStepBatch({
19
+ sim: cfg.sim,
20
+ state,
21
+ ctl,
22
+ queue,
23
+ tickOf: cfg.tickOf,
24
+ maxCatchUpFrames: 30,
25
+ });
26
+ }
27
+ else {
28
+ acc = soloStepBatch({
29
+ sim: cfg.sim,
30
+ state,
31
+ ctl,
32
+ queue,
33
+ tickOf: cfg.tickOf,
34
+ clock,
35
+ lastRef,
36
+ fps,
37
+ }, acc);
38
+ }
39
+ const stepped = cfg.tickOf(state) - before;
40
+ // Pause handshake: ack PAUSED, wait for release, rebuild on DIRTY. (Atomics.wait is worker-only.)
41
+ if (ctl.pauseReq) {
42
+ ctl.paused = true;
43
+ ctl.notify(3 /* PAUSED slot */);
44
+ // Read PAUSE_REQ atomically (not via the getter — narrowing would read it as never-changing) and block until
45
+ // the main thread clears + notifies it.
46
+ while (Atomics.load(ctl.raw(), 2 /* PAUSE_REQ */) === 1) {
47
+ Atomics.wait(ctl.raw(), 2, 1, 200);
48
+ }
49
+ ctl.paused = false;
50
+ ctl.dirty = false;
51
+ lastRef.t = clock.now();
52
+ acc = 0;
53
+ }
54
+ if (stepped > 0) {
55
+ ctl.tick = cfg.tickOf(state);
56
+ cfg.mirror?.(state, ctl); // publish worker-owned scalars for the main thread
57
+ ctx.postMessage({
58
+ t: 'hash',
59
+ hash: cfg.sim.hash(state),
60
+ tick: cfg.tickOf(state),
61
+ });
35
62
  }
36
- if (stepped > 0)
37
- ctx.postMessage({ t: 'hash', hash: cfg.sim.hash(state) });
38
63
  }
39
64
  setTimeout(loop, 1);
40
65
  };
41
66
  ctx.onmessage = (e) => {
42
67
  const m = e.data;
43
68
  if (m.t === 'init') {
44
- state = cfg.layout.attach(m.dataBuf);
69
+ const layout = typeof cfg.layout === 'function' ? cfg.layout(m) : cfg.layout;
70
+ state = layout.attach(m.dataBuf);
45
71
  ctl = createControlView(m.ctlBuf, m.scalars ?? []);
46
72
  fps = m.simFps ?? 60;
73
+ coop = !!m.coop;
47
74
  ctl.running = true;
48
75
  lastRef.t = clock.now();
49
76
  loop();
50
77
  }
78
+ else if (m.t === 'cmd') {
79
+ queue.push(m.s);
80
+ }
51
81
  else {
52
- inputQ.push(cfg.makeInput ? cfg.makeInput(m.v) : m.v);
82
+ // resync-req: keep future commands, drop the applied history (an authoritative snapshot is being loaded).
83
+ queue.prune(state ? cfg.tickOf(state) : 0);
53
84
  }
54
85
  };
55
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {