spoint 0.1.81 → 0.1.82

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.81",
3
+ "version": "0.1.82",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -127,6 +127,18 @@ export class ConnectionManager extends EventEmitter {
127
127
  return count
128
128
  }
129
129
 
130
+ sendPacked(clientId, data, unreliable) {
131
+ const client = this.clients.get(clientId)
132
+ if (!client || !client.transport.isOpen) return false
133
+ try {
134
+ if (unreliable) return client.transport.sendUnreliable(data)
135
+ return client.transport.send(data)
136
+ } catch (err) {
137
+ console.error(`[connection] sendPacked error to ${clientId}:`, err.message)
138
+ return false
139
+ }
140
+ }
141
+
130
142
  getAllStats() {
131
143
  return {
132
144
  activeConnections: this.clients.size,
@@ -28,21 +28,7 @@ function encodeEntity(e) {
28
28
  ]
29
29
  }
30
30
 
31
- function entityKey(encoded) {
32
- let k = encoded[1]
33
- for (let i = 2; i < 10; i++) k += '|' + encoded[i]
34
- k += '|' + encoded[9]
35
- if (encoded[10] !== null && encoded[10] !== undefined) k += '|' + JSON.stringify(encoded[10])
36
- return k
37
- }
38
-
39
31
  export class SnapshotEncoder {
40
- static encode(snapshot) {
41
- const players = (snapshot.players || []).map(encodePlayer)
42
- const entities = (snapshot.entities || []).map(encodeEntity)
43
- return { tick: snapshot.tick || 0, timestamp: snapshot.timestamp || 0, players, entities }
44
- }
45
-
46
32
  static encodeDelta(snapshot, prevEntityMap) {
47
33
  const players = (snapshot.players || []).map(encodePlayer)
48
34
  const currentIds = new Set()
@@ -50,11 +36,16 @@ export class SnapshotEncoder {
50
36
  const nextMap = new Map()
51
37
  for (const e of snapshot.entities || []) {
52
38
  const encoded = encodeEntity(e)
53
- const key = entityKey(encoded)
54
39
  currentIds.add(e.id)
55
- nextMap.set(e.id, key)
56
40
  const prev = prevEntityMap.get(e.id)
57
- if (prev !== key) entities.push(encoded)
41
+ let k = encoded[1]
42
+ for (let i = 2; i < 10; i++) k += '|' + encoded[i]
43
+ k += '|' + encoded[9]
44
+ const cust = encoded[10]
45
+ const custStr = (prev && prev[1] === cust) ? prev[2] : (cust != null ? JSON.stringify(cust) : '')
46
+ k += '|' + custStr
47
+ nextMap.set(e.id, [k, cust, custStr])
48
+ if (!prev || prev[0] !== k) entities.push(encoded)
58
49
  }
59
50
  const removed = []
60
51
  for (const id of prevEntityMap.keys()) {
@@ -66,6 +57,12 @@ export class SnapshotEncoder {
66
57
  }
67
58
  }
68
59
 
60
+ static encode(snapshot) {
61
+ const players = (snapshot.players || []).map(encodePlayer)
62
+ const entities = (snapshot.entities || []).map(encodeEntity)
63
+ return { tick: snapshot.tick || 0, timestamp: snapshot.timestamp || 0, players, entities }
64
+ }
65
+
69
66
  static decode(data) {
70
67
  if (data.players && Array.isArray(data.players)) {
71
68
  const players = data.players.map(p => {
@@ -0,0 +1,87 @@
1
+ import { WebSocket } from 'ws'
2
+ import { pack, unpack } from '../protocol/msgpack.js'
3
+
4
+ const CONFIG = {
5
+ botCount: 100,
6
+ durationMs: 60000,
7
+ inputHz: 60,
8
+ serverUrl: process.env.BOT_URL || 'ws://localhost:3001/ws',
9
+ batchSize: 10,
10
+ batchDelayMs: 200
11
+ }
12
+
13
+ const MSG_INPUT = 0x11
14
+ const MSG_SNAPSHOT = 0x10
15
+
16
+ function makeInput(botId, tick) {
17
+ const phase = (tick / 80 + botId * 0.37) % 1
18
+ return {
19
+ forward: phase < 0.7,
20
+ backward: phase > 0.85,
21
+ left: phase > 0.72 && phase < 0.82,
22
+ right: phase > 0.82 && phase < 0.85,
23
+ jump: tick % 200 === botId % 200,
24
+ sprint: tick % 400 < 300,
25
+ yaw: (botId / CONFIG.botCount) * Math.PI * 2 + Math.sin(tick / 180) * 0.8,
26
+ pitch: 0,
27
+ crouch: false,
28
+ interact: false
29
+ }
30
+ }
31
+
32
+ const stats = { connected: 0, snapshots: 0, errors: 0 }
33
+
34
+ function createBot(botId) {
35
+ let tick = 0
36
+ let interval = null
37
+ const ws = new WebSocket(CONFIG.serverUrl)
38
+ ws.binaryType = 'arraybuffer'
39
+ ws.on('open', () => {
40
+ stats.connected++
41
+ interval = setInterval(() => {
42
+ if (ws.readyState !== WebSocket.OPEN) return
43
+ ws.send(pack({ type: MSG_INPUT, payload: makeInput(botId, ++tick) }))
44
+ }, 1000 / CONFIG.inputHz)
45
+ })
46
+ ws.on('message', data => {
47
+ try {
48
+ const msg = unpack(data instanceof ArrayBuffer ? new Uint8Array(data) : data)
49
+ if (msg?.type === MSG_SNAPSHOT) stats.snapshots++
50
+ } catch {}
51
+ })
52
+ ws.on('error', () => { stats.errors++ })
53
+ ws.on('close', () => { stats.connected--; if (interval) clearInterval(interval) })
54
+ return ws
55
+ }
56
+
57
+ async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
58
+
59
+ async function main() {
60
+ const start = Date.now()
61
+ console.log(`[BotHarness] Connecting ${CONFIG.botCount} bots → ${CONFIG.serverUrl}`)
62
+ const bots = []
63
+ for (let i = 0; i < CONFIG.botCount; i += CONFIG.batchSize) {
64
+ const end = Math.min(i + CONFIG.batchSize, CONFIG.botCount)
65
+ for (let j = i; j < end; j++) bots.push(createBot(j))
66
+ await sleep(CONFIG.batchDelayMs)
67
+ }
68
+ await sleep(2000)
69
+ console.log(`[BotHarness] ${stats.connected}/${CONFIG.botCount} connected, running ${CONFIG.durationMs / 1000}s`)
70
+ const reportInterval = setInterval(() => {
71
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1)
72
+ const rate = (stats.snapshots / parseFloat(elapsed)).toFixed(0)
73
+ console.log(`[BotHarness] t=${elapsed}s conn=${stats.connected} snaps=${stats.snapshots} (${rate}/s) err=${stats.errors}`)
74
+ }, 5000)
75
+ await sleep(CONFIG.durationMs)
76
+ clearInterval(reportInterval)
77
+ const elapsed = (Date.now() - start) / 1000
78
+ const perBotPerSec = (stats.snapshots / CONFIG.botCount / elapsed).toFixed(2)
79
+ console.log(`\n[BotHarness] ── FINAL ──`)
80
+ console.log(`[BotHarness] Duration: ${elapsed.toFixed(1)}s | Connected: ${stats.connected}/${CONFIG.botCount}`)
81
+ console.log(`[BotHarness] Snapshots: ${stats.snapshots} total | ${perBotPerSec}/bot/sec | Errors: ${stats.errors}`)
82
+ for (const ws of bots) if (ws.readyState === WebSocket.OPEN) ws.close()
83
+ await sleep(500)
84
+ process.exit(0)
85
+ }
86
+
87
+ main().catch(e => { console.error('[BotHarness] fatal:', e); process.exit(1) })
@@ -1,8 +1,11 @@
1
1
  import { MSG } from '../protocol/MessageTypes.js'
2
2
  import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
3
+ import { pack } from '../protocol/msgpack.js'
4
+ import { isUnreliable } from '../protocol/MessageTypes.js'
3
5
  import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
4
6
 
5
7
  const KEYFRAME_INTERVAL = 128
8
+ const SNAP_GROUPS = 4
6
9
 
7
10
  export function createTickHandler(deps) {
8
11
  const {
@@ -13,23 +16,20 @@ export function createTickHandler(deps) {
13
16
  const applyMovement = _movement?.applyMovement || _applyMovement
14
17
  const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT
15
18
  const movement = { ...DEFAULT_MOVEMENT, ...m }
16
- const collisionRestitution = movement.collisionRestitution || 0.2
17
- const collisionDamping = movement.collisionDamping || 0.25
18
19
  let snapshotSeq = 0
19
-
20
20
  const playerEntityMaps = new Map()
21
21
  let broadcastEntityMap = new Map()
22
-
23
22
  let profileLog = 0
24
- const separated = new Set()
23
+ const snapUnreliable = isUnreliable(MSG.SNAPSHOT)
24
+
25
25
  return function onTick(tick, dt) {
26
26
  const t0 = performance.now()
27
27
  networkState.setTick(tick, Date.now())
28
28
  const players = playerManager.getConnectedPlayers()
29
+
29
30
  for (const player of players) {
30
31
  const inputs = playerManager.getInputs(player.id)
31
32
  const st = player.state
32
-
33
33
  if (inputs.length > 0) {
34
34
  player.lastInput = inputs[inputs.length - 1].data
35
35
  playerManager.clearInputs(player.id)
@@ -37,12 +37,14 @@ export function createTickHandler(deps) {
37
37
  const inp = player.lastInput || null
38
38
  if (inp) {
39
39
  const yaw = inp.yaw || 0
40
- st.rotation = [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)]
40
+ st.rotation[0] = 0
41
+ st.rotation[1] = Math.sin(yaw / 2)
42
+ st.rotation[2] = 0
43
+ st.rotation[3] = Math.cos(yaw / 2)
41
44
  st.crouch = inp.crouch ? 1 : 0
42
45
  st.lookPitch = inp.pitch || 0
43
46
  st.lookYaw = yaw
44
47
  }
45
-
46
48
  applyMovement(st, inp, movement, dt)
47
49
  if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch)
48
50
  const wishedVx = st.velocity[0], wishedVz = st.velocity[2]
@@ -60,82 +62,95 @@ export function createTickHandler(deps) {
60
62
  crouch: st.crouch || 0, lookPitch: st.lookPitch || 0, lookYaw: st.lookYaw || 0
61
63
  })
62
64
  }
65
+
63
66
  const t1 = performance.now()
64
- separated.clear()
67
+ const cellSz = physicsIntegration.config.capsuleRadius * 8
68
+ const minDist = physicsIntegration.config.capsuleRadius * 2
69
+ const minDist2 = minDist * minDist
70
+ const grid = new Map()
71
+ for (const p of players) {
72
+ const cx = Math.floor(p.state.position[0] / cellSz)
73
+ const cz = Math.floor(p.state.position[2] / cellSz)
74
+ const ck = cx * 65536 + cz
75
+ let cell = grid.get(ck)
76
+ if (!cell) { cell = []; grid.set(ck, cell) }
77
+ cell.push(p)
78
+ }
65
79
  for (const player of players) {
66
- const collisions = physicsIntegration.checkCollisionWithOthers(player.id, players)
67
- for (const collision of collisions) {
68
- const pairKey = player.id < collision.playerId ? `${player.id}-${collision.playerId}` : `${collision.playerId}-${player.id}`
69
- if (separated.has(pairKey)) continue
70
- separated.add(pairKey)
71
- const other = playerManager.getPlayer(collision.playerId)
72
- if (!other) continue
73
- const nx = collision.normal[0], nz = collision.normal[2]
74
- const minDist = physicsIntegration.config.capsuleRadius * 2
75
- const overlap = minDist - collision.distance
76
- const halfPush = overlap * 0.5
77
- const pushVel = Math.min(halfPush / dt, 3.0)
78
- player.state.position[0] -= nx * halfPush
79
- player.state.position[2] -= nz * halfPush
80
- player.state.velocity[0] -= nx * pushVel
81
- player.state.velocity[2] -= nz * pushVel
82
- other.state.position[0] += nx * halfPush
83
- other.state.position[2] += nz * halfPush
84
- other.state.velocity[0] += nx * pushVel
85
- other.state.velocity[2] += nz * pushVel
86
- physicsIntegration.setPlayerPosition(player.id, player.state.position)
87
- physicsIntegration.setPlayerPosition(other.id, other.state.position)
80
+ const px = player.state.position[0], py = player.state.position[1], pz = player.state.position[2]
81
+ const cx = Math.floor(px / cellSz), cz = Math.floor(pz / cellSz)
82
+ for (let ddx = -1; ddx <= 1; ddx++) {
83
+ for (let ddz = -1; ddz <= 1; ddz++) {
84
+ const neighbors = grid.get((cx + ddx) * 65536 + (cz + ddz))
85
+ if (!neighbors) continue
86
+ for (const other of neighbors) {
87
+ if (other.id <= player.id) continue
88
+ const ox = other.state.position[0], oy = other.state.position[1], oz = other.state.position[2]
89
+ const dx = ox - px, dy = oy - py, dz = oz - pz
90
+ const dist2 = dx * dx + dy * dy + dz * dz
91
+ if (dist2 >= minDist2 || dist2 === 0) continue
92
+ const distance = Math.sqrt(dist2)
93
+ const nx = dx / distance, nz = dz / distance
94
+ const overlap = minDist - distance
95
+ const halfPush = overlap * 0.5
96
+ const pushVel = Math.min(halfPush / dt, 3.0)
97
+ player.state.position[0] -= nx * halfPush
98
+ player.state.position[2] -= nz * halfPush
99
+ player.state.velocity[0] -= nx * pushVel
100
+ player.state.velocity[2] -= nz * pushVel
101
+ other.state.position[0] += nx * halfPush
102
+ other.state.position[2] += nz * halfPush
103
+ other.state.velocity[0] += nx * pushVel
104
+ other.state.velocity[2] += nz * pushVel
105
+ physicsIntegration.setPlayerPosition(player.id, player.state.position)
106
+ physicsIntegration.setPlayerPosition(other.id, other.state.position)
107
+ }
108
+ }
88
109
  }
89
110
  }
111
+
90
112
  const t2 = performance.now()
91
113
  physics.step(dt)
92
114
  const t3 = performance.now()
93
115
  appRuntime.tick(tick, dt)
94
116
  const t4 = performance.now()
117
+
95
118
  if (players.length > 0) {
96
119
  const playerSnap = networkState.getSnapshot()
97
120
  snapshotSeq++
98
121
  const isKeyframe = snapshotSeq % KEYFRAME_INTERVAL === 0
122
+ const curGroup = tick % SNAP_GROUPS
123
+
99
124
  if (stageLoader && stageLoader.getActiveStage()) {
125
+ const relevanceRadius = stageLoader.getActiveStage().spatial.relevanceRadius
100
126
  for (const player of players) {
101
- const pos = player.state.position
102
- const entitySnap = appRuntime.getSnapshotForPlayer(pos, stageLoader.getActiveStage().spatial.relevanceRadius)
127
+ if (!isKeyframe && player.id % SNAP_GROUPS !== curGroup) continue
128
+ const entitySnap = appRuntime.getSnapshotForPlayer(player.state.position, relevanceRadius)
103
129
  const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
104
- if (isKeyframe || !playerEntityMaps.has(player.id)) {
105
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
106
- playerEntityMaps.set(player.id, entityMap)
107
- connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
108
- } else {
109
- const prevMap = playerEntityMaps.get(player.id)
110
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
111
- playerEntityMaps.set(player.id, entityMap)
112
- connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
113
- }
130
+ const prevMap = (isKeyframe || !playerEntityMaps.has(player.id)) ? new Map() : playerEntityMaps.get(player.id)
131
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
132
+ playerEntityMaps.set(player.id, entityMap)
133
+ connections.send(player.id, MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
114
134
  }
115
135
  } else {
116
136
  const entitySnap = appRuntime.getSnapshot()
117
137
  const combined = { tick: playerSnap.tick, timestamp: playerSnap.timestamp, players: playerSnap.players, entities: entitySnap.entities }
118
- if (isKeyframe || broadcastEntityMap.size === 0) {
119
- const encoded = SnapshotEncoder.encode(combined)
120
- const { entityMap } = SnapshotEncoder.encodeDelta(combined, new Map())
121
- broadcastEntityMap = entityMap
122
- connections.broadcast(MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
123
- } else {
124
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, broadcastEntityMap)
125
- broadcastEntityMap = entityMap
126
- connections.broadcast(MSG.SNAPSHOT, { seq: snapshotSeq, ...encoded })
138
+ const prevMap = (isKeyframe || broadcastEntityMap.size === 0) ? new Map() : broadcastEntityMap
139
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
140
+ broadcastEntityMap = entityMap
141
+ const data = pack({ type: MSG.SNAPSHOT, payload: { seq: snapshotSeq, ...encoded } })
142
+ for (const player of players) {
143
+ if (!isKeyframe && player.id % SNAP_GROUPS !== curGroup) continue
144
+ connections.sendPacked(player.id, data, snapUnreliable)
127
145
  }
128
146
  }
129
147
  }
148
+
130
149
  for (const id of playerEntityMaps.keys()) {
131
150
  if (!playerManager.getPlayer(id)) playerEntityMaps.delete(id)
132
151
  }
133
152
  const t5 = performance.now()
134
- try {
135
- appRuntime._drainReloadQueue()
136
- } catch (e) {
137
- console.error('[TickHandler] reload queue error:', e.message)
138
- }
153
+ try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
139
154
  profileLog++
140
155
  if (profileLog % 1280 === 0) {
141
156
  const total = t5 - t0
@@ -144,7 +159,7 @@ export function createTickHandler(deps) {
144
159
  const rss = (mem.rss / 1048576).toFixed(1)
145
160
  const ext = (mem.external / 1048576).toFixed(1)
146
161
  const ab = (mem.arrayBuffers / 1048576).toFixed(1)
147
- try { console.log(`[tick-profile] tick:${tick} players:${players.length} total:${total.toFixed(2)}ms | mv:${(t1-t0).toFixed(2)} col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)} app:${(t4-t3).toFixed(2)} snap:${(t5-t4).toFixed(2)} | heap:${heap}MB rss:${rss}MB ext:${ext}MB ab:${ab}MB`) } catch (_) {}
162
+ try { console.log(`[tick-profile] tick:${tick} players:${players.length} total:${total.toFixed(2)}ms | mv:${(t1 - t0).toFixed(2)} col:${(t2 - t1).toFixed(2)} phys:${(t3 - t2).toFixed(2)} app:${(t4 - t3).toFixed(2)} snap:${(t5 - t4).toFixed(2)} | heap:${heap}MB rss:${rss}MB ext:${ext}MB ab:${ab}MB`) } catch (_) {}
148
163
  }
149
164
  }
150
165
  }