ugly-game 0.6.0 → 0.7.0

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) {
@@ -1,7 +1,7 @@
1
1
  type ArrayKind = 'f32' | 'f64' | 'i32' | 'u32' | 'u16' | 'u8' | 'i8';
2
- export interface ColumnSpec {
2
+ export interface ColumnSpec<K extends ArrayKind = ArrayKind> {
3
3
  readonly _tag: 'field';
4
- readonly kind: ArrayKind;
4
+ readonly kind: K;
5
5
  readonly len: number;
6
6
  }
7
7
  export interface ScalarSpec {
@@ -11,13 +11,13 @@ export interface ScalarSpec {
11
11
  export interface LayoutSpec {
12
12
  readonly [k: string]: ColumnSpec | ScalarSpec | LayoutSpec;
13
13
  }
14
- export declare const f32: (len: number) => ColumnSpec;
15
- export declare const f64: (len: number) => ColumnSpec;
16
- export declare const i32: (len: number) => ColumnSpec;
17
- export declare const u32: (len: number) => ColumnSpec;
18
- export declare const u16: (len: number) => ColumnSpec;
19
- export declare const u8: (len: number) => ColumnSpec;
20
- export declare const i8: (len: number) => ColumnSpec;
14
+ export declare const f32: (len: number) => ColumnSpec<"f32">;
15
+ export declare const f64: (len: number) => ColumnSpec<"f64">;
16
+ export declare const i32: (len: number) => ColumnSpec<"i32">;
17
+ export declare const u32: (len: number) => ColumnSpec<"u32">;
18
+ export declare const u16: (len: number) => ColumnSpec<"u16">;
19
+ export declare const u8: (len: number) => ColumnSpec<"u8">;
20
+ export declare const i8: (len: number) => ColumnSpec<"i8">;
21
21
  export declare const scalarI32: () => ScalarSpec;
22
22
  export declare const scalarF64: () => ScalarSpec;
23
23
  export interface ScalarCell {
@@ -25,7 +25,7 @@ export interface ScalarCell {
25
25
  set(v: number): void;
26
26
  }
27
27
  type ArrayOf<K extends ArrayKind> = K extends 'f32' ? Float32Array : K extends 'f64' ? Float64Array : K extends 'i32' ? Int32Array : K extends 'u32' ? Uint32Array : K extends 'u16' ? Uint16Array : K extends 'u8' ? Uint8Array : Int8Array;
28
- type NodeOf<N> = N extends ColumnSpec ? ArrayOf<N['kind']> : N extends ScalarSpec ? ScalarCell : N extends LayoutSpec ? StateOf<N> : never;
28
+ type NodeOf<N> = N extends ColumnSpec<infer K> ? ArrayOf<K> : N extends ScalarSpec ? ScalarCell : N extends LayoutSpec ? StateOf<N> : never;
29
29
  export type StateOf<Spec extends LayoutSpec> = {
30
30
  [K in keyof Spec]: NodeOf<Spec[K]>;
31
31
  };
@@ -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,7 @@ export interface SimHostOpts<State, Input> {
41
29
  simFps?: number;
42
30
  maxCatchUpFrames?: number;
43
31
  net?: NetAdapter;
32
+ coop?: CoopOpts;
44
33
  }
45
34
  export interface SimHost<State, Input> {
46
35
  readonly state: State;
@@ -50,6 +39,7 @@ export interface SimHost<State, Input> {
50
39
  ctl: ControlView<string>;
51
40
  hash(): string;
52
41
  netAdapter(): NetAdapter;
42
+ lockstep?: Lockstep<Input>;
53
43
  stop(): void;
54
44
  /** Fallback/test only: advance the main-thread sim to wall time `nowMs`. Undefined when a worker owns stepping. */
55
45
  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,96 @@ 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
+ });
39
46
  ctl.playing = true;
40
47
  }
48
+ // ── Lockstep driver (co-op) — lives on the main thread; feeds commands to the queue (fallback) or worker. ──
49
+ const enqueue = (s) => {
50
+ if (worker)
51
+ worker.postMessage({ t: 'cmd', s });
52
+ else
53
+ queue.push(s);
54
+ };
55
+ let lockstep;
56
+ if (opts.coop) {
57
+ lockstep = createLockstep({
58
+ transport: opts.coop.transport,
59
+ clock: opts.coop.clock,
60
+ lead: opts.coop.lead,
61
+ hashEvery: opts.coop.hashEvery,
62
+ hooks: {
63
+ onCommand: enqueue,
64
+ probeHash: probe,
65
+ hashAt: (t) => sampled.get(t),
66
+ onNeedSnap: opts.coop.onNeedSnap ?? (() => undefined),
67
+ onSnapReady: opts.coop.onSnapReady ?? (() => undefined),
68
+ },
69
+ });
70
+ ctl.coop = true;
71
+ }
41
72
  // ── Fallback path (main-thread stepping) ─────────────────────
42
73
  const lastRef = { t: clock.now() };
43
74
  let acc = 0;
75
+ const hashEvery = opts.coop?.hashEvery ?? 600;
44
76
  const pump = (nowMs) => {
45
- // The fallback clock is driven by the caller's timestamp for determinism in tests + frame-locked stepping.
46
77
  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);
78
+ if (lockstep) {
79
+ const tk = opts.tickOf(state);
80
+ if (tk % hashEvery === 0)
81
+ sampled.set(tk, probe());
82
+ ctl.targetTick = lockstep.targetTick();
83
+ lockstep.onFrame(tk);
84
+ coopStepBatch({
85
+ sim: opts.sim,
86
+ state,
87
+ ctl,
88
+ queue,
89
+ tickOf: opts.tickOf,
90
+ maxCatchUpFrames: opts.maxCatchUpFrames ?? 30,
91
+ });
92
+ }
93
+ else {
94
+ acc = soloStepBatch({
95
+ sim: opts.sim,
96
+ state,
97
+ ctl,
98
+ queue,
99
+ tickOf: opts.tickOf,
100
+ clock: drivenClock,
101
+ lastRef,
102
+ fps: opts.simFps,
103
+ maxCatchUpFrames: opts.maxCatchUpFrames,
104
+ }, acc);
105
+ }
57
106
  lastHash = opts.sim.hash(state);
58
107
  };
59
- // Solo default: nothing to catch up or resync (single-player). A co-op game passes its own NetAdapter.
60
108
  const noop = () => undefined;
61
109
  const net = opts.net ?? {
62
- window: opts.maxCatchUpFrames ?? 6,
110
+ window: opts.maxCatchUpFrames ?? 30,
63
111
  catchUp: noop,
64
- resync: noop,
112
+ resync: () => lockstep?.needSnap(),
65
113
  };
66
114
  return {
67
115
  state,
68
116
  available: useWorker,
69
117
  send(input) {
70
- if (worker) {
71
- const msg = { t: 'input', v: input };
72
- worker.postMessage(msg);
73
- }
74
- else {
75
- inputQ.push(input);
76
- }
118
+ if (lockstep)
119
+ lockstep.send(input); // stamped + forwarded; comes back via onCommand
120
+ else
121
+ enqueue({ c: input, at: opts.tickOf(state) + 1 }); // solo: apply next tick
77
122
  },
78
123
  edit(fn) {
79
124
  if (worker) {
@@ -89,12 +134,13 @@ export function createSimHost(opts) {
89
134
  }
90
135
  }
91
136
  else {
92
- fn(state); // fallback: no concurrent stepping, apply directly
137
+ fn(state);
93
138
  }
94
139
  },
95
140
  ctl,
96
141
  hash: () => lastHash,
97
142
  netAdapter: () => net,
143
+ lockstep,
98
144
  stop() {
99
145
  ctl.running = false;
100
146
  worker?.terminate();
@@ -1,8 +1,8 @@
1
- import type { DeterministicSim } from '../moat';
2
1
  import type { Layout } from './layout';
2
+ import { type SimHostSim } from './simCore';
3
3
  export interface SimWorkerConfig<S, I> {
4
- sim: DeterministicSim<S, I>;
4
+ sim: SimHostSim<S, I>;
5
5
  layout: Layout<S>;
6
- makeInput?: (raw: unknown) => I;
6
+ tickOf: (s: S) => number;
7
7
  }
8
8
  export declare function runSimWorker<S, I>(cfg: SimWorkerConfig<S, I>): void;
@@ -1,40 +1,64 @@
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
+ ctx.postMessage({
57
+ t: 'hash',
58
+ hash: cfg.sim.hash(state),
59
+ tick: cfg.tickOf(state),
60
+ });
35
61
  }
36
- if (stepped > 0)
37
- ctx.postMessage({ t: 'hash', hash: cfg.sim.hash(state) });
38
62
  }
39
63
  setTimeout(loop, 1);
40
64
  };
@@ -44,12 +68,17 @@ export function runSimWorker(cfg) {
44
68
  state = cfg.layout.attach(m.dataBuf);
45
69
  ctl = createControlView(m.ctlBuf, m.scalars ?? []);
46
70
  fps = m.simFps ?? 60;
71
+ coop = !!m.coop;
47
72
  ctl.running = true;
48
73
  lastRef.t = clock.now();
49
74
  loop();
50
75
  }
76
+ else if (m.t === 'cmd') {
77
+ queue.push(m.s);
78
+ }
51
79
  else {
52
- inputQ.push(cfg.makeInput ? cfg.makeInput(m.v) : m.v);
80
+ // resync-req: keep future commands, drop the applied history (an authoritative snapshot is being loaded).
81
+ queue.prune(state ? cfg.tickOf(state) : 0);
53
82
  }
54
83
  };
55
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {