spoint 0.1.671 → 0.1.673

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.671",
3
+ "version": "0.1.673",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -84,7 +84,6 @@
84
84
  "@gltf-transform/functions": "^4.4.0",
85
85
  "@pixiv/three-vrm": "^3.5.5",
86
86
  "@three.ez/instanced-mesh": "^0.3.15",
87
- "alea": "^1.0.1",
88
87
  "bvh.js": "^0.0.13",
89
88
  "draco3d": "^1.5.7",
90
89
  "draco3dgltf": "^1.5.7",
@@ -5,7 +5,7 @@
5
5
  // either driver.
6
6
  //
7
7
  // The one deliberate, load-bearing difference from TickSystem: dt is NEVER derived from wall-clock
8
- // measurement. TickSystem's dilationFactor (_measureTick/_onInterval) adaptively shrinks dt under
8
+ // measurement. TickSystem's dilationFactor (_onTickMeasured/_onInterval) adaptively shrinks dt under
9
9
  // server load so ONE authoritative server stays real-time-paced -- exactly the behavior a lockstep
10
10
  // peer must never exhibit, since every peer has to independently derive the IDENTICAL dt sequence
11
11
  // from tick number alone (deterministic-fixed-point-lockstep-architecture-for-rts-fighting's own
@@ -15,103 +15,34 @@
15
15
  // perf.now() delta would reintroduce the exact wall-clock leakage this row exists to bypass, so this
16
16
  // driver intentionally does NOT accumulate/measure real elapsed time to decide dt, only to decide
17
17
  // WHEN to fire the next already-fixed-size tick (wall clock only paces cadence, never sizes the step).
18
- export class LockstepTickSystem {
18
+ //
19
+ // Shares TickSystemBase's accumulator/catch-up scheduling loop with TickSystem.js verbatim -- only
20
+ // _computeDt/_onTickMeasured differ (see that file's header comment for the shared-loop rationale).
21
+ import { TickSystemBase } from './TickSystemBase.js'
22
+
23
+ export class LockstepTickSystem extends TickSystemBase {
19
24
  constructor(tickRate = 60) {
20
- this.tickRate = tickRate
21
- this.tickDuration = 1000 / tickRate
22
- this.currentTick = 0
23
- this.lastTickTime = 0
24
- this.callbacks = []
25
- this._state = 'stopped'
26
- this._reloadResolve = null
27
- this._tickInProgress = false
28
- // Fixed at 1.0, permanently -- unlike TickSystem, there is no _measureTick/_onInterval-driven
29
- // mutation path at all, so this can never silently drift under load. Kept as a real field (not a
30
- // getter constant) purely so any code doing `tickSystem.dilationFactor` for logging/diagnostics
31
- // reads the same shape as TickSystem instead of throwing on a missing property.
32
- this.dilationFactor = 1.0
33
- this._dilationCallbacks = []
34
- this._accumulator = 0
35
- this._intervalHandle = null
25
+ super(tickRate)
26
+ this._tickErrorTag = '[lockstep-tick]'
27
+ // dilationFactor stays fixed at 1.0 permanently -- unlike TickSystem, there is no
28
+ // _onTickMeasured-driven mutation path at all, so this can never silently drift under load.
36
29
  }
37
30
 
38
- get running() { return this._state === 'running' }
39
-
40
31
  // Kept for API parity with TickSystem.onDilation -- lockstep mode never dilates, so a registered
41
- // callback simply never fires. Real no-op, not a stub thrown away later: any shared caller
42
- // (TickHandler.js's server.js:297-style wiring) that unconditionally calls tickSystem.onDilation(...)
43
- // must not throw when handed a LockstepTickSystem instead of a TickSystem.
44
- onDilation(_cb) { this._dilationCallbacks.push(_cb) }
45
-
46
- onTick(callback) {
47
- if (this.callbacks.includes(callback)) return
48
- this.callbacks.push(callback)
49
- }
50
-
51
- start() {
52
- if (this.running) return
53
- this._state = 'running'
54
- this.lastTickTime = performance.now()
55
- this._accumulator = 0
56
- const intervalMs = Math.max(1, this.tickDuration / 2)
57
- this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
58
- if (this._intervalHandle.unref) this._intervalHandle.unref()
59
- }
60
-
61
- _onInterval() {
62
- if (!this.running) return
63
- const now = performance.now()
64
- this._accumulator += now - this.lastTickTime
65
- this.lastTickTime = now
66
- const maxSteps = 4
67
- const maxAccumulated = this.tickDuration * maxSteps
68
- if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
69
- let steps = 0
70
- const isPaused = this._state === 'paused'
71
- while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
72
- // dt is always the fixed, undilated tick duration -- the entire point of this driver. A stall
73
- // (debugger pause, GC, slow machine) changes HOW MANY fixed-size ticks fire in this catch-up
74
- // burst (same maxSteps cap as TickSystem), never the SIZE of any individual tick's dt.
75
- this._tickInProgress = true
76
- this.currentTick++
77
- this._accumulator -= this.tickDuration
78
- for (const callback of this.callbacks) {
79
- try {
80
- callback(this.currentTick, this.tickDuration / 1000)
81
- } catch (e) {
82
- console.error('[lockstep-tick]', e?.stack || e?.message || e)
83
- }
84
- }
85
- this._tickInProgress = false
86
- if (this._reloadResolve) {
87
- this._reloadResolve()
88
- this._reloadResolve = null
89
- }
90
- steps++
91
- }
92
- }
93
-
94
- pauseForReload() {
95
- this._state = 'paused'
96
- if (!this._tickInProgress) return Promise.resolve()
97
- return new Promise(resolve => { this._reloadResolve = resolve })
98
- }
99
-
100
- resumeAfterReload() {
101
- this._state = 'running'
102
- this.lastTickTime = performance.now()
103
- }
104
-
105
- stop() {
106
- this._state = 'stopped'
107
- if (this._intervalHandle) {
108
- clearInterval(this._intervalHandle)
109
- this._intervalHandle = null
110
- }
32
+ // callback simply never fires (still pushed into _dilationCallbacks for parity, just never invoked).
33
+ // Real no-op, not a stub thrown away later: any shared caller (TickHandler.js's server.js:297-style
34
+ // wiring) that unconditionally calls tickSystem.onDilation(...) must not throw when handed a
35
+ // LockstepTickSystem instead of a TickSystem.
36
+
37
+ _computeDt() {
38
+ // dt is always the fixed, undilated tick duration -- the entire point of this driver. A stall
39
+ // (debugger pause, GC, slow machine) changes HOW MANY fixed-size ticks fire in this catch-up
40
+ // burst (same maxSteps cap as TickSystem), never the SIZE of any individual tick's dt.
41
+ return this.tickDuration / 1000
111
42
  }
112
43
 
113
- getTick() {
114
- return this.currentTick
44
+ _onTickMeasured(_budgetMs) {
45
+ // no-op: this driver never adapts dt to measured tick cost (see header comment)
115
46
  }
116
47
 
117
48
  getTickDuration() {
@@ -1,3 +1,5 @@
1
+ import { TickSystemBase } from './TickSystemBase.js'
2
+
1
3
  const DILATION_WINDOW = 60
2
4
  const DILATION_THRESHOLD = 0.85
3
5
  const DILATION_MIN = 0.1
@@ -7,31 +9,21 @@ const DILATION_MIN_STEP = 0.01
7
9
  const DILATION_MAX_STEP = 0.25
8
10
  const DILATION_GAIN = 0.5
9
11
 
10
- export class TickSystem {
12
+ export class TickSystem extends TickSystemBase {
11
13
  // 60Hz default (was 128) -- mirrors src/sdk/server.js's config.tickRate||60; every real caller passes
12
14
  // tickRate explicitly, this is only a defensive fallback for direct instantiation.
13
15
  constructor(tickRate = 60) {
14
- this.tickRate = tickRate
15
- this.tickDuration = 1000 / tickRate
16
- this.currentTick = 0
17
- this.lastTickTime = 0
18
- this.callbacks = []
19
- this._state = 'stopped'
20
- this._reloadResolve = null
21
- this._tickInProgress = false
22
- this.dilationFactor = 1.0
23
- this._dilationCallbacks = []
16
+ super(tickRate)
17
+ this._tickErrorTag = '[tick]'
24
18
  this._tickBudgetMs = []
25
19
  this._tickBudgetSum = 0
26
- this._accumulator = 0
27
- this._intervalHandle = null
28
20
  }
29
21
 
30
- get running() { return this._state === 'running' }
31
-
32
- onDilation(cb) { this._dilationCallbacks.push(cb) }
22
+ _computeDt() {
23
+ return (this.tickDuration * this.dilationFactor) / 1000
24
+ }
33
25
 
34
- _measureTick(budget) {
26
+ _onTickMeasured(budget) {
35
27
  this._tickBudgetMs.push(budget)
36
28
  this._tickBudgetSum += budget
37
29
  if (this._tickBudgetMs.length > DILATION_WINDOW) {
@@ -57,85 +49,6 @@ export class TickSystem {
57
49
  }
58
50
  }
59
51
 
60
- onTick(callback) {
61
- // dedup by identity: re-registering the same callback must not fire it N times/tick
62
- if (this.callbacks.includes(callback)) return
63
- this.callbacks.push(callback)
64
- }
65
-
66
- start() {
67
- if (this.running) return
68
- this._state = 'running'
69
- this.lastTickTime = performance.now()
70
- this._accumulator = 0
71
- // Fixed-timestep scheduling: a single setInterval at roughly half the tick
72
- // duration drives the loop; each firing consumes as many whole ticks as have
73
- // accumulated (accumulator-based catch-up), instead of a setTimeout(...,1)/
74
- // setImmediate busy-loop that drifts and burns CPU re-scheduling every ~1ms.
75
- const intervalMs = Math.max(1, this.tickDuration / 2)
76
- this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
77
- if (this._intervalHandle.unref) this._intervalHandle.unref()
78
- }
79
-
80
- _onInterval() {
81
- if (!this.running) return
82
- const now = performance.now()
83
- this._accumulator += now - this.lastTickTime
84
- this.lastTickTime = now
85
- const maxSteps = 4
86
- // Cap the catch-up burst so a long stall (debugger pause, GC, reload) doesn't
87
- // try to replay an unbounded backlog of ticks in one go.
88
- const maxAccumulated = this.tickDuration * maxSteps
89
- if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
90
- let steps = 0
91
- const isPaused = this._state === 'paused'
92
- while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
93
- const dilatedDuration = this.tickDuration * this.dilationFactor
94
- this._tickInProgress = true
95
- this.currentTick++
96
- this._accumulator -= this.tickDuration
97
- const t0 = performance.now()
98
- for (const callback of this.callbacks) {
99
- // a throwing callback must not abort the loop / wedge pauseForReload's _tickInProgress
100
- try {
101
- callback(this.currentTick, dilatedDuration / 1000)
102
- } catch (e) {
103
- console.error('[tick]', e?.stack || e?.message || e)
104
- }
105
- }
106
- this._measureTick(performance.now() - t0)
107
- this._tickInProgress = false
108
- if (this._reloadResolve) {
109
- this._reloadResolve()
110
- this._reloadResolve = null
111
- }
112
- steps++
113
- }
114
- }
115
-
116
- pauseForReload() {
117
- this._state = 'paused'
118
- if (!this._tickInProgress) return Promise.resolve()
119
- return new Promise(resolve => { this._reloadResolve = resolve })
120
- }
121
-
122
- resumeAfterReload() {
123
- this._state = 'running'
124
- this.lastTickTime = performance.now()
125
- }
126
-
127
- stop() {
128
- this._state = 'stopped'
129
- if (this._intervalHandle) {
130
- clearInterval(this._intervalHandle)
131
- this._intervalHandle = null
132
- }
133
- }
134
-
135
- getTick() {
136
- return this.currentTick
137
- }
138
-
139
52
  getTickDuration() {
140
53
  return (this.tickDuration * this.dilationFactor) / 1000
141
54
  }
@@ -0,0 +1,107 @@
1
+ // Shared fixed-timestep tick-scheduling base for TickSystem.js (adaptive, wall-clock-dilated) and
2
+ // LockstepTickSystem.js (fixed-dt, deterministic). Both drivers need the IDENTICAL accumulator/catch-up
3
+ // scheduling loop (a single setInterval at ~half the tick duration, consuming whole accumulated ticks
4
+ // per firing, capped at maxSteps=4 so a stall's catch-up burst is bounded) -- what differs is ONLY how
5
+ // each computes the dt handed to callbacks and whether it measures tick cost to adapt dilationFactor.
6
+ // Subclasses override _computeDt() (the per-tick dt in seconds) and _onTickMeasured(budgetMs) (called
7
+ // with each tick's real wall-clock cost, a no-op for the deterministic lockstep driver).
8
+
9
+ export class TickSystemBase {
10
+ constructor(tickRate = 60) {
11
+ this.tickRate = tickRate
12
+ this.tickDuration = 1000 / tickRate
13
+ this.currentTick = 0
14
+ this.lastTickTime = 0
15
+ this.callbacks = []
16
+ this._state = 'stopped'
17
+ this._reloadResolve = null
18
+ this._tickInProgress = false
19
+ this.dilationFactor = 1.0
20
+ this._dilationCallbacks = []
21
+ this._accumulator = 0
22
+ this._intervalHandle = null
23
+ }
24
+
25
+ get running() { return this._state === 'running' }
26
+
27
+ onDilation(cb) { this._dilationCallbacks.push(cb) }
28
+
29
+ onTick(callback) {
30
+ // dedup by identity: re-registering the same callback must not fire it N times/tick
31
+ if (this.callbacks.includes(callback)) return
32
+ this.callbacks.push(callback)
33
+ }
34
+
35
+ start() {
36
+ if (this.running) return
37
+ this._state = 'running'
38
+ this.lastTickTime = performance.now()
39
+ this._accumulator = 0
40
+ // Fixed-timestep scheduling: a single setInterval at roughly half the tick
41
+ // duration drives the loop; each firing consumes as many whole ticks as have
42
+ // accumulated (accumulator-based catch-up), instead of a setTimeout(...,1)/
43
+ // setImmediate busy-loop that drifts and burns CPU re-scheduling every ~1ms.
44
+ const intervalMs = Math.max(1, this.tickDuration / 2)
45
+ this._intervalHandle = setInterval(() => this._onInterval(), intervalMs)
46
+ if (this._intervalHandle.unref) this._intervalHandle.unref()
47
+ }
48
+
49
+ _onInterval() {
50
+ if (!this.running) return
51
+ const now = performance.now()
52
+ this._accumulator += now - this.lastTickTime
53
+ this.lastTickTime = now
54
+ const maxSteps = 4
55
+ // Cap the catch-up burst so a long stall (debugger pause, GC, reload) doesn't
56
+ // try to replay an unbounded backlog of ticks in one go.
57
+ const maxAccumulated = this.tickDuration * maxSteps
58
+ if (this._accumulator > maxAccumulated) this._accumulator = maxAccumulated
59
+ let steps = 0
60
+ const isPaused = this._state === 'paused'
61
+ while (this._accumulator >= this.tickDuration && !isPaused && steps < maxSteps) {
62
+ const dt = this._computeDt()
63
+ this._tickInProgress = true
64
+ this.currentTick++
65
+ this._accumulator -= this.tickDuration
66
+ const t0 = performance.now()
67
+ for (const callback of this.callbacks) {
68
+ // a throwing callback must not abort the loop / wedge pauseForReload's _tickInProgress
69
+ try {
70
+ callback(this.currentTick, dt)
71
+ } catch (e) {
72
+ console.error(this._tickErrorTag, e?.stack || e?.message || e)
73
+ }
74
+ }
75
+ this._onTickMeasured(performance.now() - t0)
76
+ this._tickInProgress = false
77
+ if (this._reloadResolve) {
78
+ this._reloadResolve()
79
+ this._reloadResolve = null
80
+ }
81
+ steps++
82
+ }
83
+ }
84
+
85
+ pauseForReload() {
86
+ this._state = 'paused'
87
+ if (!this._tickInProgress) return Promise.resolve()
88
+ return new Promise(resolve => { this._reloadResolve = resolve })
89
+ }
90
+
91
+ resumeAfterReload() {
92
+ this._state = 'running'
93
+ this.lastTickTime = performance.now()
94
+ }
95
+
96
+ stop() {
97
+ this._state = 'stopped'
98
+ if (this._intervalHandle) {
99
+ clearInterval(this._intervalHandle)
100
+ this._intervalHandle = null
101
+ }
102
+ }
103
+
104
+ getTick() {
105
+ return this.currentTick
106
+ }
107
+ }