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 +2 -0
- package/dist/index.js +2 -0
- package/dist/loop/commandQueue.d.ts +16 -0
- package/dist/loop/commandQueue.js +39 -0
- package/dist/loop/controlBlock.d.ts +4 -0
- package/dist/loop/controlBlock.js +11 -1
- package/dist/loop/layout.d.ts +10 -10
- package/dist/loop/lockstep.d.ts +37 -0
- package/dist/loop/lockstep.js +63 -0
- package/dist/loop/simCore.d.ts +42 -0
- package/dist/loop/simCore.js +58 -0
- package/dist/loop/simHost.d.ts +14 -24
- package/dist/loop/simHost.js +74 -28
- package/dist/loop/simHost.worker.d.ts +3 -3
- package/dist/loop/simHost.worker.js +55 -26
- package/package.json +1 -1
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 =
|
|
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) {
|
package/dist/loop/layout.d.ts
CHANGED
|
@@ -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:
|
|
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<
|
|
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
|
+
}
|
package/dist/loop/simCore.d.ts
CHANGED
|
@@ -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;
|
package/dist/loop/simCore.js
CHANGED
|
@@ -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
|
package/dist/loop/simHost.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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;
|
package/dist/loop/simHost.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createControlView, controlByteLength, } from './controlBlock';
|
|
2
|
-
import {
|
|
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
|
-
|
|
38
|
+
worker.postMessage({
|
|
32
39
|
t: 'init',
|
|
33
40
|
ctlBuf,
|
|
34
41
|
dataBuf,
|
|
35
42
|
scalars,
|
|
36
43
|
simFps: opts.simFps ?? 60,
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 ??
|
|
110
|
+
window: opts.maxCatchUpFrames ?? 30,
|
|
63
111
|
catchUp: noop,
|
|
64
|
-
resync:
|
|
112
|
+
resync: () => lockstep?.needSnap(),
|
|
65
113
|
};
|
|
66
114
|
return {
|
|
67
115
|
state,
|
|
68
116
|
available: useWorker,
|
|
69
117
|
send(input) {
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
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);
|
|
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:
|
|
4
|
+
sim: SimHostSim<S, I>;
|
|
5
5
|
layout: Layout<S>;
|
|
6
|
-
|
|
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 {
|
|
2
|
+
import { soloStepBatch, coopStepBatch, } from './simCore';
|
|
3
|
+
import { CommandQueue } from './commandQueue';
|
|
3
4
|
export function runSimWorker(cfg) {
|
|
4
5
|
const ctx = globalThis;
|
|
5
|
-
const
|
|
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 =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|