-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtest-load.js
More file actions
162 lines (152 loc) · 9.43 KB
/
Copy pathtest-load.js
File metadata and controls
162 lines (152 loc) · 9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// test-load.js -- real-module load + Byzantine witness (sibling to test.js).
//
// test.js locks the CORRECTNESS contracts; this file is the second root-level witness for the
// Crucible(max-load) + Epistemology(profiling Monte-Carlo) invariants the abstract architecture
// ask names. It is mock-free and real-module-only: it drives the REAL TickHandler priority
// selection across a synthetic crowd and measures p50/p95/p99, and it asserts the REAL Byzantine
// trust-boundary guards reject hostile input without throwing. No network shaping / no synthetic
// sockets -- that full chaos harness is a distinct project and deliberately out of scope here.
//
// Run: node test-load.js (exits 1 on any failed assertion or a blown degradation budget)
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { getPlayerPriorityIds, PRIORITY_ENTITY_BUDGET } from './src/sdk/TickHandler.js'
import { SnapshotEncoder } from './src/netcode/SnapshotEncoder.js'
let pass = 0, fail = 0
const _tests = []
function test(name, fn) { _tests.push({ name, fn }) }
function percentile(sortedNs, p) {
if (sortedNs.length === 0) return 0
const idx = Math.min(sortedNs.length - 1, Math.floor((p / 100) * sortedNs.length))
return sortedNs[idx]
}
// A real dynamic-cache, built by the REAL SnapshotEncoder over synthetic-but-real-shaped entities.
// getPlayerPriorityIds reads enc[2..4] (position) and enc[6..8] (velocity) per id -- exactly the
// fields fillEntityEnc populates, so the synthetic crowd exercises the genuine hot path.
function buildCrowd(n) {
const entities = new Map()
const activeIds = new Set()
for (let i = 1; i <= n; i++) {
// spread the crowd in a 200m box around the viewer, with motion, so the score buckets fill.
const a = (i * 2.39996) % (Math.PI * 2)
const r = (i % 100)
entities.set(i, {
id: i, model: '', bodyType: 'dynamic', custom: null,
position: [Math.cos(a) * r, ((i * 7) % 40) - 20, Math.sin(a) * r],
rotation: [0, 0, 0, 1],
velocity: [((i % 7) - 3), 0, ((i % 5) - 2)],
scale: [1, 1, 1],
})
activeIds.add(i)
}
const dynCache = SnapshotEncoder.buildDynamicCache(activeIds, new Set(), new Set(), entities, null)
return { dynCache, relevantIds: [...activeIds] }
}
// ---- (a) + (c) load / p99 + graceful-degradation gate -------------------------------------------
const LOAD_RESULTS = []
test('priority selection p99 stays within budget across N=50..500 (graceful degradation)', () => {
const Ns = [50, 100, 250, 500]
const ITER = 400 // ticks per N -> Monte-Carlo sample for stable percentiles
for (const n of Ns) {
const { dynCache, relevantIds } = buildCrowd(n)
const viewerPos = [0, 0, 0]
const samples = []
// warm the accumulator for this playerId so steady-state cost (not first-touch alloc) is measured.
const PID = 1000 + n
getPlayerPriorityIds(PID, relevantIds, dynCache, viewerPos, 0)
for (let t = 1; t <= ITER; t++) {
const t0 = process.hrtime.bigint()
const out = getPlayerPriorityIds(PID, relevantIds, dynCache, viewerPos, t)
const dt = Number(process.hrtime.bigint() - t0)
samples.push(dt)
// contract: once the crowd exceeds the budget the result is a bounded Set of <= budget ids;
// below budget it returns the full relevant list (idempotent passthrough).
if (n > PRIORITY_ENTITY_BUDGET) {
assert.ok(out instanceof Set, `N=${n}: over-budget result must be a Set`)
assert.ok(out.size <= PRIORITY_ENTITY_BUDGET, `N=${n}: selected ${out.size} > budget ${PRIORITY_ENTITY_BUDGET}`)
}
}
samples.sort((a, b) => a - b)
const p50 = percentile(samples, 50), p95 = percentile(samples, 95), p99 = percentile(samples, 99)
LOAD_RESULTS.push({ n, p50, p95, p99 })
console.log(` [load] N=${String(n).padStart(4)} p50=${(p50/1000).toFixed(2)}us p95=${(p95/1000).toFixed(2)}us p99=${(p99/1000).toFixed(2)}us`)
}
// Degradation gate: even the worst crowd (N=500) must keep the per-call p99 under ~20% of a single
// 128Hz tick (7.8ms). The selection is one of MANY per-tick costs; a 1.5ms p99 ceiling leaves head-
// room for a noisy micro-bench GC tail while still catching an O(R^2)/sort regression (which the
// scale-ratio test below pins more tightly). p50/p95 are the steady-state truth and run ~0.1/0.5ms.
const worst = LOAD_RESULTS[LOAD_RESULTS.length - 1]
const P99_BUDGET_NS = 1500000
assert.ok(worst.p99 <= P99_BUDGET_NS, `N=${worst.n} priority p99 ${(worst.p99/1000).toFixed(1)}us exceeds ${(P99_BUDGET_NS/1000)}us budget`)
})
// The measurement that decides land-deferred-priority-ids-topk: is selection cost super-linear in N?
test('priority selection scales sub-quadratically in N (no hidden sort/O(R^2))', () => {
// p99 at N=500 vs N=50: a true O(R log R) sort or O(R^2) pass would blow up far faster than the
// ~10x work increase. Assert the ratio is bounded well under quadratic (100x).
const small = LOAD_RESULTS.find(r => r.n === 50), big = LOAD_RESULTS.find(r => r.n === 500)
const ratio = big.p99 / Math.max(1, small.p99)
console.log(` [scale] p99 ratio N500/N50 = ${ratio.toFixed(1)}x (work ratio 10x; quadratic would be ~100x)`)
assert.ok(ratio < 40, `p99 grew ${ratio.toFixed(1)}x for 10x work -- looks super-linear (sort/O(R^2) regression?)`)
})
// ---- (b) Byzantine trust-boundary guards --------------------------------------------------------
// SnapshotEncoder.decode IS a real exported Byzantine boundary for wire data: a truncated positional
// array would silently read past the end into NaN geometry, so it drops malformed records.
test('SnapshotEncoder.decode drops truncated/malformed wire records (no NaN poison)', () => {
const decoded = SnapshotEncoder.decode({
tick: 1, serverTime: 0,
players: [[1, 0, 0, 0, 0, 0, 0, 0, 1, 100, 0, 0, 0], [2, 0, 0]], // 2nd is short (len 3 < 13)
entities: [[1, '', 0, 0, 0, 0, 0, 0, 0, 'dynamic', null, 1, 1, 1, 0], [2, '']], // 2nd short (<15)
})
assert.equal(decoded.players.length, 1, 'short player record dropped')
assert.equal(decoded.entities.length, 1, 'short entity record dropped')
for (const p of decoded.players) for (const c of p.position) assert.ok(Number.isFinite(c), 'no NaN in surviving player position')
})
// The server-side guards live inline in the ServerHandlers message closure (not extractable as pure
// predicates without churn), so witness them two ways: (1) the exact guard PREDICATES reject hostile
// input without throwing, and (2) the live source still CARRIES those guards (source-shape lock so a
// future edit that drops a guard fails this witness).
test('Byzantine predicates reject hostile trimesh/health/token/rtt without throwing', () => {
// trimesh guard (ServerHandlers MSG.TRIMESH_DATA): array shape, len%3, finiteness, index bounds, cap.
const MAX_VERTS = 300000, MAX_TRIS = 200000
const trimeshOk = (vertices, indices) => {
if (!vertices || !indices) return false
if (!Array.isArray(vertices) || !Array.isArray(indices)) return false
if (vertices.length % 3 !== 0 || vertices.length / 3 > MAX_VERTS) return false
if (indices.length % 3 !== 0 || indices.length / 3 > MAX_TRIS) return false
const vertCount = vertices.length / 3
for (const v of vertices) if (!Number.isFinite(v)) return false
for (const i of indices) if (!Number.isInteger(i) || i < 0 || i >= vertCount) return false
return true
}
assert.equal(trimeshOk(null, null), false, 'missing arrays rejected')
assert.equal(trimeshOk('evil', [0, 1, 2]), false, 'non-array vertices rejected')
assert.equal(trimeshOk([0, 0, 0, 1, 1, 1, 2, 2, 2], [0, 1, 99]), false, 'out-of-range index rejected')
assert.equal(trimeshOk([0, 0, 0, NaN, 1, 1, 2, 2, 2], [0, 1, 2]), false, 'NaN vertex rejected')
assert.equal(trimeshOk([0, 0, 0, 1, 1, 1, 2, 2, 2], [0, 1, 2]), true, 'valid trimesh accepted')
// health clamp [0, maxHealth]; sessionToken string+len>=8; rtt cap 10000.
const maxHealth = 100
const clampHealth = h => (Number.isFinite(h) && h >= 0) ? Math.min(h, maxHealth) : maxHealth
assert.equal(clampHealth(1e308), maxHealth, 'tampered huge health clamped (no unkillable reconnect)')
assert.equal(clampHealth(-5), maxHealth, 'negative health -> default')
assert.equal(clampHealth(42), 42, 'valid health preserved')
const tokenOk = t => typeof t === 'string' && t.length >= 8
assert.equal(tokenOk({}), false, 'non-string token rejected')
assert.equal(tokenOk('short'), false, 'too-short token rejected')
assert.equal(tokenOk('a-valid-token'), true, 'plausible token accepted')
const capRtt = r => r < 0 ? 0 : (r > 10000 ? 10000 : r)
assert.equal(capRtt(1e9), 10000, 'spoofed far-past timestamp rtt capped')
assert.equal(capRtt(-50), 0, 'negative rtt floored')
// source-shape lock: the live ServerHandlers still carries each guard.
const sh = readFileSync(new URL('./src/sdk/ServerHandlers.js', import.meta.url), 'utf8')
assert.match(sh, /vertices\.length\s*%\s*3/, 'trimesh len%3 guard present in source')
assert.match(sh, /Number\.isInteger/, 'trimesh integer-index guard present in source')
assert.match(sh, /Math\.min\(savedState\.health,\s*_maxHealth\)/, 'health clamp present in source')
assert.match(sh, /typeof\s+_token\s*!==\s*'string'/, 'sessionToken string guard present in source')
assert.match(sh, /client\.rtt\s*>\s*10000/, 'rtt cap present in source')
})
for (const { name, fn } of _tests) {
try { await fn(); console.log('PASS', name); pass++ }
catch (e) { console.error('FAIL', name, e.message); fail++ }
}
console.log(`\n${pass} passed, ${fail} failed`)
if (fail > 0) process.exit(1)