ugly-game 0.5.2 → 0.5.4
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/input/browserBackend.d.ts +4 -0
- package/dist/input/browserBackend.js +19 -1
- package/dist/net/commands.d.ts +47 -0
- package/dist/net/commands.js +98 -0
- package/dist/net/divergence.d.ts +21 -0
- package/dist/net/divergence.js +43 -0
- package/dist/net/index.d.ts +7 -0
- package/dist/net/index.js +10 -0
- package/dist/net/lww.d.ts +19 -0
- package/dist/net/lww.js +38 -0
- package/dist/net/protocol.d.ts +24 -0
- package/dist/net/protocol.js +20 -0
- package/dist/net/reconcile.d.ts +24 -0
- package/dist/net/reconcile.js +82 -0
- package/dist/net/roomCore.d.ts +31 -0
- package/dist/net/roomCore.js +146 -0
- package/dist/net/roomSocket.d.ts +43 -0
- package/dist/net/roomSocket.js +140 -0
- package/dist/net/sharedClock.d.ts +21 -0
- package/dist/net/sharedClock.js +51 -0
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ export declare class BrowserRawBackend {
|
|
|
27
27
|
private paused;
|
|
28
28
|
constructor(opts: BrowserBackendOpts);
|
|
29
29
|
setPaused(p: boolean): void;
|
|
30
|
+
/** Drop every held key / mouse button / touch. Called when the window can no longer observe releases (blur,
|
|
31
|
+
* tab hidden, pointer-lock lost) so a key held across the disruption can't stick "on" and drive the avatar
|
|
32
|
+
* forever. Public so a host can force it (e.g. right before opening a modal that steals focus). */
|
|
33
|
+
releaseHeld(): void;
|
|
30
34
|
private on;
|
|
31
35
|
private cursor;
|
|
32
36
|
private gesture;
|
|
@@ -36,6 +36,10 @@ export class BrowserRawBackend {
|
|
|
36
36
|
paused = false; // while a menu is open: suspend gameplay input + don't grab pointer lock
|
|
37
37
|
constructor(opts) { this.target = opts.target; this.pointerLock = opts.pointerLock ?? false; this.attach(); }
|
|
38
38
|
setPaused(p) { this.paused = p; }
|
|
39
|
+
/** Drop every held key / mouse button / touch. Called when the window can no longer observe releases (blur,
|
|
40
|
+
* tab hidden, pointer-lock lost) so a key held across the disruption can't stick "on" and drive the avatar
|
|
41
|
+
* forever. Public so a host can force it (e.g. right before opening a modal that steals focus). */
|
|
42
|
+
releaseHeld() { this.keysDown.clear(); this.touches.clear(); this.pointerButtons.clear(); }
|
|
39
43
|
on(el, type, fn, o) {
|
|
40
44
|
const handler = fn;
|
|
41
45
|
el.addEventListener(type, handler, o);
|
|
@@ -54,7 +58,21 @@ export class BrowserRawBackend {
|
|
|
54
58
|
this.on(window, 'keydown', (e) => { if (!this.keysDown.has(e.code))
|
|
55
59
|
this.edges.add('key:' + e.code); this.keysDown.add(e.code); });
|
|
56
60
|
this.on(window, 'keyup', (e) => { this.keysDown.delete(e.code); });
|
|
57
|
-
|
|
61
|
+
// RELEASE ALL held input whenever the window can no longer see the keys being let go. `blur` alone is not
|
|
62
|
+
// enough: on macOS an app-switch (Cmd+Tab) delivers the keyup to the background, and the reliable page-side
|
|
63
|
+
// signal is `visibilitychange`→hidden (blur can be missed). Losing pointer lock (Esc / menu) is the same
|
|
64
|
+
// hazard. Without this a movement key held at switch-time stays in keysDown and the avatar walks that way
|
|
65
|
+
// forever ("stuck always going backward"). We clear ONLY on the disruptive edge — never when the tab becomes
|
|
66
|
+
// visible again or when lock is ACQUIRED, which would wrongly drop a key the player is legitimately holding.
|
|
67
|
+
this.on(window, 'blur', () => { this.releaseHeld(); });
|
|
68
|
+
const onHidden = () => { if (document.hidden)
|
|
69
|
+
this.releaseHeld(); };
|
|
70
|
+
const onUnlock = () => { if (document.pointerLockElement !== this.target)
|
|
71
|
+
this.releaseHeld(); };
|
|
72
|
+
document.addEventListener('visibilitychange', onHidden);
|
|
73
|
+
document.addEventListener('pointerlockchange', onUnlock);
|
|
74
|
+
this.disposers.push(() => { document.removeEventListener('visibilitychange', onHidden); });
|
|
75
|
+
this.disposers.push(() => { document.removeEventListener('pointerlockchange', onUnlock); });
|
|
58
76
|
this.on(this.target, 'wheel', (e) => { this.pwheel += e.deltaY; e.preventDefault(); }, { passive: false });
|
|
59
77
|
this.on(this.target, 'contextmenu', (e) => { e.preventDefault(); });
|
|
60
78
|
this.on(this.target, 'pointerdown', (e) => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** A command stamped for the agreed tick, with the server's total-order seq + issuer id. */
|
|
2
|
+
export interface Stamped<C> {
|
|
3
|
+
c: C;
|
|
4
|
+
at: number;
|
|
5
|
+
seq?: number;
|
|
6
|
+
by?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Ordered command buffer: insert stamped commands, drain the ones DUE at a tick in a stable order. */
|
|
9
|
+
export declare class CommandQueue<C> {
|
|
10
|
+
private q;
|
|
11
|
+
/** Insert; keeps the buffer sorted by (at, seq, by) so every client drains in the identical order. */
|
|
12
|
+
add(s: Stamped<C>): void;
|
|
13
|
+
/** All commands that apply exactly ON `tick`, in order (removed from the queue). */
|
|
14
|
+
due(tick: number): Stamped<C>[];
|
|
15
|
+
/** How many buffered commands are already LATE (at < tick) — a divergence signal. */
|
|
16
|
+
lateCount(tick: number): number;
|
|
17
|
+
/** Drop everything strictly before `tick` (post-resync prune). */
|
|
18
|
+
prune(tick: number): void;
|
|
19
|
+
clear(): void;
|
|
20
|
+
get size(): number;
|
|
21
|
+
}
|
|
22
|
+
/** Order-sensitive rolling digest of the APPLIED command stream. Two clients that applied the same commands in
|
|
23
|
+
* the same order share a journal — so a journal match + a state MISMATCH localises the bug to the sim (not the
|
|
24
|
+
* relay), and a journal mismatch localises it to ordering/relay. */
|
|
25
|
+
export declare class CommandJournal {
|
|
26
|
+
private h;
|
|
27
|
+
private n;
|
|
28
|
+
record(at: number, seq: number, by: string): void;
|
|
29
|
+
get digest(): string;
|
|
30
|
+
get count(): number;
|
|
31
|
+
}
|
|
32
|
+
/** A ring of recent (frame → state clone) checkpoints for cheap rewind, bounded to `capacity` frames. Keyed by
|
|
33
|
+
* frame, so re-checkpointing a frame during replay REPLACES it (the corrected state), and the oldest frames
|
|
34
|
+
* evict once the window is full. Stores + hands out owned clones. */
|
|
35
|
+
export declare class CheckpointRing<S> {
|
|
36
|
+
private clone;
|
|
37
|
+
private capacity;
|
|
38
|
+
private m;
|
|
39
|
+
constructor(clone: (s: S) => S, capacity?: number);
|
|
40
|
+
put(frame: number, state: S): void;
|
|
41
|
+
/** The newest checkpoint at or before `frame` (owned clone), or null if it has scrolled out of the ring. */
|
|
42
|
+
atOrBefore(frame: number): {
|
|
43
|
+
frame: number;
|
|
44
|
+
state: S;
|
|
45
|
+
} | null;
|
|
46
|
+
get oldest(): number;
|
|
47
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// COMMANDS — the generic lockstep command plumbing, extracted from cogsworth's
|
|
3
|
+
// factoryCommand.ts so any deterministic game reuses it: an ordered queue keyed by
|
|
4
|
+
// (tick, seq), an order-sensitive JOURNAL digest (separates sim-nondeterminism from
|
|
5
|
+
// relay bugs), and a CHECKPOINT ring for cheap rewind. Generic over the command
|
|
6
|
+
// payload C and the state S. Deterministic: integer ordering + fnv1a only.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
import { fnv1a } from '../stateTree';
|
|
9
|
+
/** Ordered command buffer: insert stamped commands, drain the ones DUE at a tick in a stable order. */
|
|
10
|
+
export class CommandQueue {
|
|
11
|
+
q = [];
|
|
12
|
+
/** Insert; keeps the buffer sorted by (at, seq, by) so every client drains in the identical order. */
|
|
13
|
+
add(s) {
|
|
14
|
+
this.q.push(s);
|
|
15
|
+
this.q.sort((a, b) => a.at - b.at ||
|
|
16
|
+
(a.seq ?? 0) - (b.seq ?? 0) ||
|
|
17
|
+
(a.by ?? '').localeCompare(b.by ?? ''));
|
|
18
|
+
}
|
|
19
|
+
/** All commands that apply exactly ON `tick`, in order (removed from the queue). */
|
|
20
|
+
due(tick) {
|
|
21
|
+
const out = [];
|
|
22
|
+
while (this.q.length && this.q[0].at <= tick) {
|
|
23
|
+
if (this.q[0].at === tick)
|
|
24
|
+
out.push(this.q.shift());
|
|
25
|
+
else
|
|
26
|
+
this.q.shift(); // an at<tick command is LATE — dropped here; the caller tracks it as divergence
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** How many buffered commands are already LATE (at < tick) — a divergence signal. */
|
|
31
|
+
lateCount(tick) {
|
|
32
|
+
return this.q.filter((s) => s.at < tick).length;
|
|
33
|
+
}
|
|
34
|
+
/** Drop everything strictly before `tick` (post-resync prune). */
|
|
35
|
+
prune(tick) {
|
|
36
|
+
this.q = this.q.filter((s) => s.at >= tick);
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.q = [];
|
|
40
|
+
}
|
|
41
|
+
get size() {
|
|
42
|
+
return this.q.length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Order-sensitive rolling digest of the APPLIED command stream. Two clients that applied the same commands in
|
|
46
|
+
* the same order share a journal — so a journal match + a state MISMATCH localises the bug to the sim (not the
|
|
47
|
+
* relay), and a journal mismatch localises it to ordering/relay. */
|
|
48
|
+
export class CommandJournal {
|
|
49
|
+
h = 'seed';
|
|
50
|
+
n = 0;
|
|
51
|
+
record(at, seq, by) {
|
|
52
|
+
this.h = fnv1a(`${this.h}|${at}:${seq}:${by}`);
|
|
53
|
+
this.n++;
|
|
54
|
+
}
|
|
55
|
+
get digest() {
|
|
56
|
+
return this.h;
|
|
57
|
+
}
|
|
58
|
+
get count() {
|
|
59
|
+
return this.n;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A ring of recent (frame → state clone) checkpoints for cheap rewind, bounded to `capacity` frames. Keyed by
|
|
63
|
+
* frame, so re-checkpointing a frame during replay REPLACES it (the corrected state), and the oldest frames
|
|
64
|
+
* evict once the window is full. Stores + hands out owned clones. */
|
|
65
|
+
export class CheckpointRing {
|
|
66
|
+
clone;
|
|
67
|
+
capacity;
|
|
68
|
+
m = new Map();
|
|
69
|
+
constructor(clone, capacity = 120) {
|
|
70
|
+
this.clone = clone;
|
|
71
|
+
this.capacity = capacity;
|
|
72
|
+
}
|
|
73
|
+
put(frame, state) {
|
|
74
|
+
this.m.set(frame, this.clone(state));
|
|
75
|
+
while (this.m.size > this.capacity) {
|
|
76
|
+
let oldest = Infinity;
|
|
77
|
+
for (const f of this.m.keys())
|
|
78
|
+
if (f < oldest)
|
|
79
|
+
oldest = f;
|
|
80
|
+
this.m.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The newest checkpoint at or before `frame` (owned clone), or null if it has scrolled out of the ring. */
|
|
84
|
+
atOrBefore(frame) {
|
|
85
|
+
let best = -Infinity;
|
|
86
|
+
for (const f of this.m.keys())
|
|
87
|
+
if (f <= frame && f > best)
|
|
88
|
+
best = f;
|
|
89
|
+
return best === -Infinity ? null : { frame: best, state: this.clone(this.m.get(best)) };
|
|
90
|
+
}
|
|
91
|
+
get oldest() {
|
|
92
|
+
let o = Infinity;
|
|
93
|
+
for (const f of this.m.keys())
|
|
94
|
+
if (f < o)
|
|
95
|
+
o = f;
|
|
96
|
+
return o;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type DivergenceStatus = 'pending' | 'ok' | 'diverged';
|
|
2
|
+
export interface DivergenceResult {
|
|
3
|
+
status: DivergenceStatus;
|
|
4
|
+
iResync?: boolean;
|
|
5
|
+
peer?: string;
|
|
6
|
+
tick?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class DivergenceMonitor {
|
|
9
|
+
private me;
|
|
10
|
+
private gridEvery;
|
|
11
|
+
private local;
|
|
12
|
+
private remote;
|
|
13
|
+
constructor(me: string, gridEvery?: number);
|
|
14
|
+
/** Both clients probe on the SAME ticks (multiples of the grid) without coordinating. */
|
|
15
|
+
isProbeTick(tick: number): boolean;
|
|
16
|
+
/** Record + compare our own hash at a probe tick. */
|
|
17
|
+
recordLocal(tick: number, hash: string): DivergenceResult;
|
|
18
|
+
/** Record + compare a peer's hash. */
|
|
19
|
+
recordRemote(tick: number, hash: string, by: string): DivergenceResult;
|
|
20
|
+
private compare;
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// DIVERGENCE MONITOR — lockstep desync detection + recovery arbitration. Clients
|
|
3
|
+
// sample their state hash on a FIXED tick grid (so both pick the same ticks with no
|
|
4
|
+
// coordination) and exchange them. On a mismatch, a TOTAL-ORDER tiebreak (`me > by`)
|
|
5
|
+
// decides exactly ONE side to yield and resync from the other's snapshot — arbitrary
|
|
6
|
+
// but AGREED, so there's no resync ping-pong. Extracted from cogsworth's onHash path.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
export class DivergenceMonitor {
|
|
9
|
+
me;
|
|
10
|
+
gridEvery;
|
|
11
|
+
local = new Map();
|
|
12
|
+
remote = new Map();
|
|
13
|
+
constructor(me, gridEvery = 600) {
|
|
14
|
+
this.me = me;
|
|
15
|
+
this.gridEvery = gridEvery;
|
|
16
|
+
}
|
|
17
|
+
/** Both clients probe on the SAME ticks (multiples of the grid) without coordinating. */
|
|
18
|
+
isProbeTick(tick) {
|
|
19
|
+
return tick % this.gridEvery === 0;
|
|
20
|
+
}
|
|
21
|
+
/** Record + compare our own hash at a probe tick. */
|
|
22
|
+
recordLocal(tick, hash) {
|
|
23
|
+
this.local.set(tick, hash);
|
|
24
|
+
return this.compare(tick);
|
|
25
|
+
}
|
|
26
|
+
/** Record + compare a peer's hash. */
|
|
27
|
+
recordRemote(tick, hash, by) {
|
|
28
|
+
if (by === this.me)
|
|
29
|
+
return { status: 'pending' }; // ignore our own echo
|
|
30
|
+
this.remote.set(tick, { hash, by });
|
|
31
|
+
return this.compare(tick);
|
|
32
|
+
}
|
|
33
|
+
compare(tick) {
|
|
34
|
+
const l = this.local.get(tick);
|
|
35
|
+
const r = this.remote.get(tick);
|
|
36
|
+
if (l === undefined || r === undefined)
|
|
37
|
+
return { status: 'pending', tick };
|
|
38
|
+
if (l === r.hash)
|
|
39
|
+
return { status: 'ok', tick, peer: r.by };
|
|
40
|
+
// divergence — the HIGHER id yields (fetches the lower id's snapshot). Exactly one side moves.
|
|
41
|
+
return { status: 'diverged', iResync: this.me > r.by, peer: r.by, tick };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `ugly-game/net` — the CLIENT + pure deterministic-netcode surface. The server room (roomCore.ts) is
|
|
2
|
+
// DELIBERATELY not re-exported here: it uses Cloudflare Workers globals and must stay reachable ONLY as
|
|
3
|
+
// `ugly-game/net/roomCore` so a browser bundle importing `ugly-game/net` never pulls server code.
|
|
4
|
+
export * from './protocol';
|
|
5
|
+
export * from './commands';
|
|
6
|
+
export * from './lww';
|
|
7
|
+
export * from './sharedClock';
|
|
8
|
+
export * from './divergence';
|
|
9
|
+
export * from './reconcile';
|
|
10
|
+
export * from './roomSocket';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface LwwEdit<K, V> {
|
|
2
|
+
key: K;
|
|
3
|
+
val: V;
|
|
4
|
+
stamp: number;
|
|
5
|
+
by: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class LwwStore<K, V> {
|
|
8
|
+
private map;
|
|
9
|
+
private clock;
|
|
10
|
+
/** Local edit → assign the next stamp (max-seen + 1) and apply. Returns the wire edit to broadcast. */
|
|
11
|
+
set(key: K, val: V, by: string): LwwEdit<K, V>;
|
|
12
|
+
/** Apply a REMOTE edit under last-write-wins. Advances the local clock past the seen stamp so our next local
|
|
13
|
+
* edit outranks it. Returns true if this edit won (the visible value changed) → the app re-renders that cell. */
|
|
14
|
+
apply(e: LwwEdit<K, V>): boolean;
|
|
15
|
+
get(key: K): V | undefined;
|
|
16
|
+
/** The full delta set — sent to a joining peer so they reconstruct base ⊕ deltas. */
|
|
17
|
+
snapshot(): LwwEdit<K, V>[];
|
|
18
|
+
get size(): number;
|
|
19
|
+
}
|
package/dist/net/lww.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// LWW STORE — a last-write-wins keyed delta store for FREE-ROAM games (all-mine-sky
|
|
3
|
+
// voxel edits). Each edit carries a monotonic stamp (lamport-ish: max-seen+1, tie-
|
|
4
|
+
// broken by author id) so concurrent edits to the same cell converge identically on
|
|
5
|
+
// every client regardless of arrival order — no shared tick, applied on arrival.
|
|
6
|
+
// Doubles as the save format ("send current state on join"). Deterministic.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
export class LwwStore {
|
|
9
|
+
map = new Map();
|
|
10
|
+
clock = 0;
|
|
11
|
+
/** Local edit → assign the next stamp (max-seen + 1) and apply. Returns the wire edit to broadcast. */
|
|
12
|
+
set(key, val, by) {
|
|
13
|
+
const e = { key, val, stamp: ++this.clock, by };
|
|
14
|
+
this.map.set(key, e);
|
|
15
|
+
return e;
|
|
16
|
+
}
|
|
17
|
+
/** Apply a REMOTE edit under last-write-wins. Advances the local clock past the seen stamp so our next local
|
|
18
|
+
* edit outranks it. Returns true if this edit won (the visible value changed) → the app re-renders that cell. */
|
|
19
|
+
apply(e) {
|
|
20
|
+
if (e.stamp > this.clock)
|
|
21
|
+
this.clock = e.stamp;
|
|
22
|
+
const cur = this.map.get(e.key);
|
|
23
|
+
if (cur && (cur.stamp > e.stamp || (cur.stamp === e.stamp && cur.by >= e.by)))
|
|
24
|
+
return false; // an existing write outranks it
|
|
25
|
+
this.map.set(e.key, e);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
get(key) {
|
|
29
|
+
return this.map.get(key)?.val;
|
|
30
|
+
}
|
|
31
|
+
/** The full delta set — sent to a joining peer so they reconstruct base ⊕ deltas. */
|
|
32
|
+
snapshot() {
|
|
33
|
+
return [...this.map.values()];
|
|
34
|
+
}
|
|
35
|
+
get size() {
|
|
36
|
+
return this.map.size;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Every wire message. `t` is the tag; the rest is tag-specific (opaque for game payloads). */
|
|
2
|
+
export interface RoomMsg {
|
|
3
|
+
t: string;
|
|
4
|
+
[k: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
/** Well-known control tags the platform interprets. Game payloads ride under `relay`/`cur` and are opaque. */
|
|
7
|
+
export declare const TAG: {
|
|
8
|
+
readonly hello: "hello";
|
|
9
|
+
readonly ping: "ping";
|
|
10
|
+
readonly pong: "pong";
|
|
11
|
+
readonly cur: "cur";
|
|
12
|
+
readonly relay: "relay";
|
|
13
|
+
readonly hash: "hash";
|
|
14
|
+
readonly needSnap: "need-snap";
|
|
15
|
+
readonly snapReady: "snap-ready";
|
|
16
|
+
readonly epoch: "epoch";
|
|
17
|
+
readonly repin: "repin";
|
|
18
|
+
readonly bye: "bye";
|
|
19
|
+
};
|
|
20
|
+
export type Tag = (typeof TAG)[keyof typeof TAG];
|
|
21
|
+
/** A relayed wire message after the server assigns total order (distinct from commands.Stamped<C>). */
|
|
22
|
+
export interface StampedMsg extends RoomMsg {
|
|
23
|
+
seq: number;
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// NET PROTOCOL — the message envelope shared by the room server (RoomCore) and the
|
|
3
|
+
// client (RoomSocket). The platform relays a small set of WELL-KNOWN control tags
|
|
4
|
+
// and treats the game payloads under `relay`/`cur` as fully OPAQUE, so lockstep
|
|
5
|
+
// (cogsworth) and free-roam (all-mine-sky) reuse the same transport unchanged.
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
/** Well-known control tags the platform interprets. Game payloads ride under `relay`/`cur` and are opaque. */
|
|
8
|
+
export const TAG = {
|
|
9
|
+
hello: 'hello', // server→client on connect: { peers, epoch, now }
|
|
10
|
+
ping: 'ping', // client→server: { c } (client stamp) ; server→ pong { now, c }
|
|
11
|
+
pong: 'pong',
|
|
12
|
+
cur: 'cur', // presence beacon → fanned to OTHERS, unordered, not echoed (ephemeral)
|
|
13
|
+
relay: 'relay', // an ordered game edit/command → stamped { seq } and fanned to EVERYONE incl. sender
|
|
14
|
+
hash: 'hash', // divergence probe → fanned to everyone incl. sender
|
|
15
|
+
needSnap: 'need-snap', // join/resync: ask a peer to flush a snapshot (bulk fetched over HTTP)
|
|
16
|
+
snapReady: 'snap-ready',
|
|
17
|
+
epoch: 'epoch', // server→client after a repin
|
|
18
|
+
repin: 'repin', // lone client re-pins the shared-clock epoch to its own tick
|
|
19
|
+
bye: 'bye', // a peer left (server→others) : { by }
|
|
20
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MoatSim } from '../moat';
|
|
2
|
+
export interface Correction {
|
|
3
|
+
rewound: number;
|
|
4
|
+
diverged: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare class Reconciler<S, I> {
|
|
7
|
+
private sim;
|
|
8
|
+
private ring;
|
|
9
|
+
private inputs;
|
|
10
|
+
private st;
|
|
11
|
+
private f;
|
|
12
|
+
private window;
|
|
13
|
+
constructor(sim: MoatSim<S, I>, base: S, baseFrame?: number, window?: number);
|
|
14
|
+
/** Step the head forward by applying `input` for the current head frame → advance to head+1. */
|
|
15
|
+
advance(input: I): void;
|
|
16
|
+
/** An authoritative/corrected input for `frame` arrives. If it's a past frame within the window, rewind and
|
|
17
|
+
* replay to head; if it's the future, just buffer it (applied when advance() reaches it); if it predates the
|
|
18
|
+
* window, return rewound<0 (the app should resync from a snapshot). */
|
|
19
|
+
correct(frame: number, input: I): Correction;
|
|
20
|
+
private pruneInputs;
|
|
21
|
+
get state(): S;
|
|
22
|
+
get frame(): number;
|
|
23
|
+
hash(): string;
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// RECONCILER — client-side rollback over any MoatSim (the moat's netcode payoff).
|
|
3
|
+
//
|
|
4
|
+
// Live play can't wait for the network: you PREDICT missing/remote inputs and step
|
|
5
|
+
// ahead. When an authoritative (or corrected) input for a PAST frame arrives, you
|
|
6
|
+
// rewind to a checkpoint at/just-before that frame, splice the input into the frame-
|
|
7
|
+
// indexed stream, and REPLAY forward to the present. Because the sim is a
|
|
8
|
+
// deterministic MoatSim (clone/hash/step), the replayed head is BYTE-IDENTICAL to
|
|
9
|
+
// what an in-order application would have produced — that's the whole guarantee, and
|
|
10
|
+
// `reconcile.test.mts` proves it via hash equality. Bounded by a checkpoint window;
|
|
11
|
+
// corrections older than the window return rewound<0 so the app can trigger a resync.
|
|
12
|
+
//
|
|
13
|
+
// This is the generic sibling of the branchable StateTree (full saves / AI explore):
|
|
14
|
+
// StateTree branches for exploration; the Reconciler keeps a bounded LINEAR ring for
|
|
15
|
+
// live rollback. Both are the "savestate substrate" the design calls for.
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
import { CheckpointRing } from './commands';
|
|
18
|
+
export class Reconciler {
|
|
19
|
+
sim;
|
|
20
|
+
ring;
|
|
21
|
+
inputs = new Map(); // frame f → the input applied AT f (producing frame f+1)
|
|
22
|
+
st;
|
|
23
|
+
f; // head frame
|
|
24
|
+
window;
|
|
25
|
+
constructor(sim, base, baseFrame = 0, window = 120) {
|
|
26
|
+
this.sim = sim;
|
|
27
|
+
this.window = window;
|
|
28
|
+
this.ring = new CheckpointRing((s) => sim.clone(s), window);
|
|
29
|
+
this.st = sim.clone(base);
|
|
30
|
+
this.f = baseFrame;
|
|
31
|
+
this.ring.put(this.f, this.st);
|
|
32
|
+
}
|
|
33
|
+
/** Step the head forward by applying `input` for the current head frame → advance to head+1. */
|
|
34
|
+
advance(input) {
|
|
35
|
+
this.inputs.set(this.f, input);
|
|
36
|
+
this.sim.step(this.st, input);
|
|
37
|
+
this.f++;
|
|
38
|
+
this.ring.put(this.f, this.st);
|
|
39
|
+
this.pruneInputs();
|
|
40
|
+
}
|
|
41
|
+
/** An authoritative/corrected input for `frame` arrives. If it's a past frame within the window, rewind and
|
|
42
|
+
* replay to head; if it's the future, just buffer it (applied when advance() reaches it); if it predates the
|
|
43
|
+
* window, return rewound<0 (the app should resync from a snapshot). */
|
|
44
|
+
correct(frame, input) {
|
|
45
|
+
if (frame >= this.f) {
|
|
46
|
+
this.inputs.set(frame, input); // future input — buffered for when advance() reaches it
|
|
47
|
+
return { rewound: -1, diverged: false };
|
|
48
|
+
}
|
|
49
|
+
const cp = this.ring.atOrBefore(frame);
|
|
50
|
+
if (!cp)
|
|
51
|
+
return { rewound: -1, diverged: false }; // scrolled out of the window
|
|
52
|
+
const before = this.sim.hash(this.st);
|
|
53
|
+
this.inputs.set(frame, input);
|
|
54
|
+
let s = cp.state; // an owned clone
|
|
55
|
+
for (let g = cp.frame; g < this.f; g++) {
|
|
56
|
+
const inp = this.inputs.get(g);
|
|
57
|
+
if (inp === undefined)
|
|
58
|
+
continue; // no input recorded at g → the sim was idle that frame
|
|
59
|
+
this.sim.step(s, inp);
|
|
60
|
+
this.ring.put(g + 1, s); // refresh the checkpoint with the corrected trajectory
|
|
61
|
+
}
|
|
62
|
+
this.st = s;
|
|
63
|
+
return { rewound: cp.frame, diverged: this.sim.hash(this.st) !== before };
|
|
64
|
+
}
|
|
65
|
+
pruneInputs() {
|
|
66
|
+
const keep = this.f - this.window;
|
|
67
|
+
if (keep <= 0)
|
|
68
|
+
return;
|
|
69
|
+
for (const g of this.inputs.keys())
|
|
70
|
+
if (g < keep)
|
|
71
|
+
this.inputs.delete(g);
|
|
72
|
+
}
|
|
73
|
+
get state() {
|
|
74
|
+
return this.st;
|
|
75
|
+
}
|
|
76
|
+
get frame() {
|
|
77
|
+
return this.f;
|
|
78
|
+
}
|
|
79
|
+
hash() {
|
|
80
|
+
return this.sim.hash(this.st);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
interface WS {
|
|
2
|
+
send(data: string): void;
|
|
3
|
+
close(code?: number, reason?: string): void;
|
|
4
|
+
}
|
|
5
|
+
interface DOStorage {
|
|
6
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
7
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
8
|
+
delete(key: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
interface DOState {
|
|
11
|
+
storage: DOStorage;
|
|
12
|
+
acceptWebSocket(ws: WS): void;
|
|
13
|
+
getWebSockets(): WS[];
|
|
14
|
+
}
|
|
15
|
+
export interface RoomOptions {
|
|
16
|
+
/** ms per tick for the shared clock epoch (lockstep games). Free-roam games can ignore the epoch. */
|
|
17
|
+
tickMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class RoomCore {
|
|
20
|
+
private state;
|
|
21
|
+
private opts;
|
|
22
|
+
private seq;
|
|
23
|
+
constructor(state: DOState, opts?: RoomOptions);
|
|
24
|
+
fetch(request: Request): Promise<unknown>;
|
|
25
|
+
/** The app DO forwards its webSocketMessage here. */
|
|
26
|
+
webSocketMessage(ws: WS, message: string | ArrayBuffer): void;
|
|
27
|
+
webSocketClose(ws: WS): void;
|
|
28
|
+
webSocketError(ws: WS): void;
|
|
29
|
+
private json;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// ROOM CORE — the generic multiplayer room, extracted from cogsworth's GameDO. A
|
|
3
|
+
// game's Durable Object is a THIN delegate that forwards fetch/webSocket* to a
|
|
4
|
+
// RoomCore instance; RoomCore owns the transport and treats game payloads as OPAQUE,
|
|
5
|
+
// so lockstep (cogsworth) and free-roam (all-mine-sky) reuse it unchanged.
|
|
6
|
+
//
|
|
7
|
+
// Runs on Cloudflare Workers. Cloudflare types are declared INLINE (as cogsworth's
|
|
8
|
+
// GameDO already does) so this module needs no @cloudflare/workers-types dependency
|
|
9
|
+
// and never leaks Workers types into the client build. NEVER re-export from index.ts
|
|
10
|
+
// and NEVER import from a client module — reachable only as `ugly-game/net/roomCore`.
|
|
11
|
+
//
|
|
12
|
+
// The server NEVER simulates; it only assigns TOTAL ORDER (a seq) and fans out:
|
|
13
|
+
// · `relay` / `hash` → stamped { seq } and sent to EVERYONE incl. the sender
|
|
14
|
+
// · `cur` (presence) → sent to OTHERS, unordered, not echoed
|
|
15
|
+
// · `ping` → `pong { now, c }` (client clock-offset estimation)
|
|
16
|
+
// · `need-snap`/`snap-ready` → fan out to others (bulk snapshot bytes go over HTTP)
|
|
17
|
+
// · `repin` → lone client re-pins the shared-clock epoch
|
|
18
|
+
// Snapshot bytes are stored chunked (PUT/GET) for join/resync + save. WebSocket
|
|
19
|
+
// HIBERNATION (state.acceptWebSocket) means no duration billed while a room idles.
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
const CHUNK = 96 * 1024; // DO value cap is ~128KB; chunk snapshots under it
|
|
22
|
+
const KEYS = { epoch: 'epoch', chunks: 'chunks', bytes: 'bytes', tick: 'tick' };
|
|
23
|
+
export class RoomCore {
|
|
24
|
+
state;
|
|
25
|
+
opts;
|
|
26
|
+
seq = 0; // in-memory total-order counter, re-derived from the live set, never persisted
|
|
27
|
+
constructor(state, opts = {}) {
|
|
28
|
+
this.state = state;
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
}
|
|
31
|
+
async fetch(request) {
|
|
32
|
+
const url = new URL(request.url);
|
|
33
|
+
const path = url.pathname.split('/').filter(Boolean).pop() ?? '';
|
|
34
|
+
// ── WebSocket upgrade (hibernatable) ──
|
|
35
|
+
if (path === 'ws' || url.searchParams.get('ws') === '1') {
|
|
36
|
+
if (request.headers.get('Upgrade') !== 'websocket')
|
|
37
|
+
return new Response('expected websocket', { status: 426 });
|
|
38
|
+
const pair = new WebSocketPair();
|
|
39
|
+
this.state.acceptWebSocket(pair[1]);
|
|
40
|
+
let epoch = await this.state.storage.get(KEYS.epoch);
|
|
41
|
+
if (epoch === undefined) {
|
|
42
|
+
epoch = Date.now() - (await this.state.storage.get(KEYS.tick) ?? 0) * (this.opts.tickMs ?? 0);
|
|
43
|
+
await this.state.storage.put(KEYS.epoch, epoch);
|
|
44
|
+
}
|
|
45
|
+
pair[1].send(JSON.stringify({ t: 'hello', peers: this.state.getWebSockets().length, epoch, now: Date.now() }));
|
|
46
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
47
|
+
}
|
|
48
|
+
// ── snapshot SAVE (join/resync + persistence): body = opaque bytes, stored chunked ──
|
|
49
|
+
if (request.method === 'PUT' || request.method === 'POST') {
|
|
50
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
51
|
+
if (body.length === 0 || body.length > 16 * 1024 * 1024)
|
|
52
|
+
return this.json({ error: 'bad snapshot size' }, 400);
|
|
53
|
+
const n = Math.ceil(body.length / CHUNK);
|
|
54
|
+
for (let i = 0; i < n; i++)
|
|
55
|
+
await this.state.storage.put(`snap:${i}`, body.slice(i * CHUNK, (i + 1) * CHUNK));
|
|
56
|
+
for (let i = n; i < (await this.state.storage.get(KEYS.chunks) ?? 0); i++)
|
|
57
|
+
await this.state.storage.delete(`snap:${i}`);
|
|
58
|
+
await this.state.storage.put(KEYS.chunks, n);
|
|
59
|
+
await this.state.storage.put(KEYS.bytes, body.length);
|
|
60
|
+
if (url.searchParams.has('tick'))
|
|
61
|
+
await this.state.storage.put(KEYS.tick, Number(url.searchParams.get('tick')));
|
|
62
|
+
return this.json({ ok: true, bytes: body.length, chunks: n });
|
|
63
|
+
}
|
|
64
|
+
// ── snapshot LOAD ──
|
|
65
|
+
const chunks = (await this.state.storage.get(KEYS.chunks)) ?? 0;
|
|
66
|
+
if (chunks === 0)
|
|
67
|
+
return this.json({ error: 'not found' }, 404);
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (let i = 0; i < chunks; i++) {
|
|
70
|
+
const p = await this.state.storage.get(`snap:${i}`);
|
|
71
|
+
if (p)
|
|
72
|
+
parts.push(new Uint8Array(p));
|
|
73
|
+
}
|
|
74
|
+
const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0));
|
|
75
|
+
let o = 0;
|
|
76
|
+
for (const p of parts) {
|
|
77
|
+
out.set(p, o);
|
|
78
|
+
o += p.length;
|
|
79
|
+
}
|
|
80
|
+
return new Response(out, {
|
|
81
|
+
status: 200,
|
|
82
|
+
headers: {
|
|
83
|
+
'content-type': 'application/octet-stream',
|
|
84
|
+
'x-room-tick': String((await this.state.storage.get(KEYS.tick)) ?? 0),
|
|
85
|
+
'cache-control': 'no-store',
|
|
86
|
+
'x-ugly-no-spa-fallback': '1',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** The app DO forwards its webSocketMessage here. */
|
|
91
|
+
webSocketMessage(ws, message) {
|
|
92
|
+
let m = null;
|
|
93
|
+
try {
|
|
94
|
+
m = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!m || typeof m.t !== 'string')
|
|
100
|
+
return;
|
|
101
|
+
if (m.t === 'ping') {
|
|
102
|
+
ws.send(JSON.stringify({ t: 'pong', now: Date.now(), c: m.c }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (m.t === 'repin' && typeof m.tick === 'number' && this.state.getWebSockets().length === 1) {
|
|
106
|
+
const epoch = Date.now() - m.tick * (this.opts.tickMs ?? 0);
|
|
107
|
+
void this.state.storage.put(KEYS.epoch, epoch);
|
|
108
|
+
ws.send(JSON.stringify({ t: 'epoch', epoch, now: Date.now() }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (m.t === 'cur' || m.t === 'need-snap' || m.t === 'snap-ready') {
|
|
112
|
+
const s = JSON.stringify(m); // ephemeral / handshake → fan out to OTHERS only
|
|
113
|
+
for (const peer of this.state.getWebSockets())
|
|
114
|
+
if (peer !== ws)
|
|
115
|
+
peer.send(s);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// `relay` (an ordered edit/command) + `hash` (a divergence probe) → total-ordered, fanned to EVERYONE incl.
|
|
119
|
+
// the sender (a lockstep issuer must apply its own edit on the same agreed tick as its peers).
|
|
120
|
+
const stamped = JSON.stringify({ ...m, seq: ++this.seq });
|
|
121
|
+
for (const peer of this.state.getWebSockets())
|
|
122
|
+
peer.send(stamped);
|
|
123
|
+
}
|
|
124
|
+
webSocketClose(ws) {
|
|
125
|
+
try {
|
|
126
|
+
ws.close(1000, 'bye');
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* already gone */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
webSocketError(ws) {
|
|
133
|
+
try {
|
|
134
|
+
ws.close(1011, 'error');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* already gone */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
json(obj, status = 200) {
|
|
141
|
+
return new Response(JSON.stringify(obj), {
|
|
142
|
+
status,
|
|
143
|
+
headers: { 'content-type': 'application/json', 'x-ugly-no-spa-fallback': '1' },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RoomMsg } from './protocol';
|
|
2
|
+
export interface RoomSocketOpts {
|
|
3
|
+
url: string;
|
|
4
|
+
me: string;
|
|
5
|
+
onMessage: (m: RoomMsg) => void;
|
|
6
|
+
onOpen?: (hello: {
|
|
7
|
+
peers: number;
|
|
8
|
+
epoch: number;
|
|
9
|
+
now: number;
|
|
10
|
+
}) => void;
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
onPong?: (sentAt: number, serverNow: number) => void;
|
|
13
|
+
presenceHz?: number;
|
|
14
|
+
pingMs?: number;
|
|
15
|
+
reconnect?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare class RoomSocket {
|
|
18
|
+
private o;
|
|
19
|
+
private ws;
|
|
20
|
+
private closed;
|
|
21
|
+
private lastPresence;
|
|
22
|
+
private presencePending;
|
|
23
|
+
private presenceTimer;
|
|
24
|
+
private pingTimer;
|
|
25
|
+
private backoff;
|
|
26
|
+
readonly me: string;
|
|
27
|
+
constructor(o: RoomSocketOpts);
|
|
28
|
+
private connect;
|
|
29
|
+
get ready(): boolean;
|
|
30
|
+
private raw;
|
|
31
|
+
/** An ORDERED edit/command (`relay`) or a divergence probe (`hash`) — echoed back stamped with a total-order seq. */
|
|
32
|
+
send(m: RoomMsg): void;
|
|
33
|
+
hashProbe(tick: number, hash: string): void;
|
|
34
|
+
needSnap(to?: string): void;
|
|
35
|
+
snapReady(to?: string): void;
|
|
36
|
+
repin(tick: number): void;
|
|
37
|
+
/** Presence beacon (`cur`) — coalesced to presenceHz so a fast cursor/pose doesn't flood the relay. */
|
|
38
|
+
presence(m: RoomMsg): void;
|
|
39
|
+
private ping;
|
|
40
|
+
private pingBurst;
|
|
41
|
+
private stopTimers;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// ROOM SOCKET — the client half of the room, extracted + generalized from
|
|
3
|
+
// cogsworth's coop.ts. Owns the WebSocket lifecycle (connect + AUTO-RECONNECT with
|
|
4
|
+
// backoff — cogsworth's lacked this), the ping loop feeding clock-offset estimation,
|
|
5
|
+
// presence throttling, and typed send/route. Game-agnostic: it ships/receives opaque
|
|
6
|
+
// `relay`/`cur`/`hash` payloads and hands them to the app via one `onMessage`.
|
|
7
|
+
// Browser client module (uses WebSocket) — never imports the server RoomCore.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
export class RoomSocket {
|
|
10
|
+
o;
|
|
11
|
+
ws = null;
|
|
12
|
+
closed = false;
|
|
13
|
+
lastPresence = 0;
|
|
14
|
+
presencePending = null;
|
|
15
|
+
presenceTimer = null;
|
|
16
|
+
pingTimer = null;
|
|
17
|
+
backoff = 500;
|
|
18
|
+
me;
|
|
19
|
+
constructor(o) {
|
|
20
|
+
this.o = o;
|
|
21
|
+
this.me = o.me;
|
|
22
|
+
this.connect();
|
|
23
|
+
}
|
|
24
|
+
connect() {
|
|
25
|
+
if (this.closed)
|
|
26
|
+
return;
|
|
27
|
+
const ws = new WebSocket(this.o.url);
|
|
28
|
+
this.ws = ws;
|
|
29
|
+
ws.onopen = () => {
|
|
30
|
+
this.backoff = 500;
|
|
31
|
+
this.pingBurst();
|
|
32
|
+
this.pingTimer = setInterval(() => this.ping(), this.o.pingMs ?? 15000);
|
|
33
|
+
};
|
|
34
|
+
ws.onmessage = (ev) => {
|
|
35
|
+
let m = null;
|
|
36
|
+
try {
|
|
37
|
+
m = JSON.parse(String(ev.data));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!m || typeof m.t !== 'string')
|
|
43
|
+
return;
|
|
44
|
+
if (m.t === 'pong') {
|
|
45
|
+
this.o.onPong?.(Number(m.c), Number(m.now));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (m.t === 'hello') {
|
|
49
|
+
this.o.onOpen?.({ peers: Number(m.peers), epoch: Number(m.epoch), now: Number(m.now) });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.o.onMessage(m);
|
|
53
|
+
};
|
|
54
|
+
ws.onclose = () => {
|
|
55
|
+
this.stopTimers();
|
|
56
|
+
this.o.onClose?.();
|
|
57
|
+
if (!this.closed && (this.o.reconnect ?? true)) {
|
|
58
|
+
setTimeout(() => this.connect(), this.backoff);
|
|
59
|
+
this.backoff = Math.min(this.backoff * 2, 8000);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
ws.onerror = () => {
|
|
63
|
+
try {
|
|
64
|
+
ws.close();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* noop */
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
get ready() {
|
|
72
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
73
|
+
}
|
|
74
|
+
raw(m) {
|
|
75
|
+
if (this.ready)
|
|
76
|
+
this.ws.send(JSON.stringify(m));
|
|
77
|
+
}
|
|
78
|
+
/** An ORDERED edit/command (`relay`) or a divergence probe (`hash`) — echoed back stamped with a total-order seq. */
|
|
79
|
+
send(m) {
|
|
80
|
+
this.raw(m);
|
|
81
|
+
}
|
|
82
|
+
hashProbe(tick, hash) {
|
|
83
|
+
this.raw({ t: 'hash', tick, hash, by: this.me });
|
|
84
|
+
}
|
|
85
|
+
needSnap(to) {
|
|
86
|
+
this.raw({ t: 'need-snap', by: this.me, to });
|
|
87
|
+
}
|
|
88
|
+
snapReady(to) {
|
|
89
|
+
this.raw({ t: 'snap-ready', by: this.me, to });
|
|
90
|
+
}
|
|
91
|
+
repin(tick) {
|
|
92
|
+
this.raw({ t: 'repin', tick });
|
|
93
|
+
}
|
|
94
|
+
/** Presence beacon (`cur`) — coalesced to presenceHz so a fast cursor/pose doesn't flood the relay. */
|
|
95
|
+
presence(m) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const minGap = 1000 / (this.o.presenceHz ?? 8);
|
|
98
|
+
if (now - this.lastPresence >= minGap) {
|
|
99
|
+
this.lastPresence = now;
|
|
100
|
+
this.raw({ ...m, t: 'cur', by: this.me });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.presencePending = { ...m, t: 'cur', by: this.me };
|
|
104
|
+
if (!this.presenceTimer)
|
|
105
|
+
this.presenceTimer = setTimeout(() => {
|
|
106
|
+
this.presenceTimer = null;
|
|
107
|
+
if (this.presencePending) {
|
|
108
|
+
this.lastPresence = Date.now();
|
|
109
|
+
this.raw(this.presencePending);
|
|
110
|
+
this.presencePending = null;
|
|
111
|
+
}
|
|
112
|
+
}, minGap - (now - this.lastPresence));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
ping() {
|
|
116
|
+
this.raw({ t: 'ping', c: Date.now() });
|
|
117
|
+
}
|
|
118
|
+
pingBurst() {
|
|
119
|
+
this.ping();
|
|
120
|
+
setTimeout(() => this.ping(), 400);
|
|
121
|
+
setTimeout(() => this.ping(), 900);
|
|
122
|
+
}
|
|
123
|
+
stopTimers() {
|
|
124
|
+
if (this.pingTimer)
|
|
125
|
+
clearInterval(this.pingTimer);
|
|
126
|
+
if (this.presenceTimer)
|
|
127
|
+
clearTimeout(this.presenceTimer);
|
|
128
|
+
this.pingTimer = this.presenceTimer = null;
|
|
129
|
+
}
|
|
130
|
+
close() {
|
|
131
|
+
this.closed = true;
|
|
132
|
+
this.stopTimers();
|
|
133
|
+
try {
|
|
134
|
+
this.ws?.close();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* noop */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare class SharedClock {
|
|
2
|
+
private tickMs;
|
|
3
|
+
private now;
|
|
4
|
+
private epoch;
|
|
5
|
+
private offset;
|
|
6
|
+
private bestRtt;
|
|
7
|
+
private pinned;
|
|
8
|
+
constructor(tickMs: number, // ms per tick (e.g. 1000/60)
|
|
9
|
+
now: () => number);
|
|
10
|
+
/** From the server `hello`. */
|
|
11
|
+
setEpoch(epoch: number): void;
|
|
12
|
+
get hasEpoch(): boolean;
|
|
13
|
+
get epochMs(): number;
|
|
14
|
+
/** Feed a ping round-trip: `sentAt` = local ms we sent, `serverNow` = server ms in the pong. Keeps the sample
|
|
15
|
+
* with the smallest round-trip (its midpoint is the least-biased estimate of the server clock). */
|
|
16
|
+
onPong(sentAt: number, serverNow: number): void;
|
|
17
|
+
/** The tick the whole room should be on right now. */
|
|
18
|
+
targetTick(): number;
|
|
19
|
+
get offsetMs(): number;
|
|
20
|
+
get rttMs(): number;
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// SHARED CLOCK — "what tick is it, everywhere, right now?" (lockstep games). The
|
|
3
|
+
// room pins an EPOCH: the server-clock instant of tick 0. Each client estimates its
|
|
4
|
+
// offset from the server clock via ping/pong (keeping the FASTEST RTT sample — the
|
|
5
|
+
// least-jittered), then derives targetTick = (serverNow - epoch) / T. Every client
|
|
6
|
+
// chases the same tick without per-frame coordination. Extracted from cogsworth.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
export class SharedClock {
|
|
9
|
+
tickMs;
|
|
10
|
+
now;
|
|
11
|
+
epoch = 0; // server-clock ms of tick 0
|
|
12
|
+
offset = 0; // serverClock - localClock (ms)
|
|
13
|
+
bestRtt = Infinity;
|
|
14
|
+
pinned = false;
|
|
15
|
+
constructor(tickMs, // ms per tick (e.g. 1000/60)
|
|
16
|
+
now) {
|
|
17
|
+
this.tickMs = tickMs;
|
|
18
|
+
this.now = now;
|
|
19
|
+
}
|
|
20
|
+
/** From the server `hello`. */
|
|
21
|
+
setEpoch(epoch) {
|
|
22
|
+
this.epoch = epoch;
|
|
23
|
+
this.pinned = true;
|
|
24
|
+
}
|
|
25
|
+
get hasEpoch() {
|
|
26
|
+
return this.pinned;
|
|
27
|
+
}
|
|
28
|
+
get epochMs() {
|
|
29
|
+
return this.epoch;
|
|
30
|
+
}
|
|
31
|
+
/** Feed a ping round-trip: `sentAt` = local ms we sent, `serverNow` = server ms in the pong. Keeps the sample
|
|
32
|
+
* with the smallest round-trip (its midpoint is the least-biased estimate of the server clock). */
|
|
33
|
+
onPong(sentAt, serverNow) {
|
|
34
|
+
const recvAt = this.now();
|
|
35
|
+
const rtt = recvAt - sentAt;
|
|
36
|
+
if (rtt < this.bestRtt) {
|
|
37
|
+
this.bestRtt = rtt;
|
|
38
|
+
this.offset = serverNow + rtt / 2 - recvAt; // server clock at receive ≈ serverNow + rtt/2
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The tick the whole room should be on right now. */
|
|
42
|
+
targetTick() {
|
|
43
|
+
return Math.floor((this.now() + this.offset - this.epoch) / this.tickMs);
|
|
44
|
+
}
|
|
45
|
+
get offsetMs() {
|
|
46
|
+
return this.offset;
|
|
47
|
+
}
|
|
48
|
+
get rttMs() {
|
|
49
|
+
return this.bestRtt;
|
|
50
|
+
}
|
|
51
|
+
}
|