@wenathlan/stealthhead 0.0.0-stage → 2.0.117
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/README.md +31 -2
- package/docs/.gitkeep +0 -0
- package/package.json +161 -4
- package/tests/.gitkeep +0 -0
- package/tests/design.test.ts +160 -0
- package/tests/family.test.ts +81 -0
- package/tests/familyurl.test.ts +25 -0
- package/tests/hud.test.ts +204 -0
- package/tests/matchrules.test.ts +219 -0
- package/tests/netcode.test.ts +251 -0
- package/tests/onboardingstate.test.ts +62 -0
- package/tests/physics.test.ts +185 -0
- package/tests/rankingladder.test.ts +162 -0
- package/tests/spawner.test.ts +210 -0
- package/tests/weaponstats.test.ts +148 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// # hud.test — honest unit tests for the hud state logic (vital bands,
|
|
2
|
+
// minimap transforms, hitmarkers, killfeed, damage pings), runnable with
|
|
3
|
+
// the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// Every case feeds plain structs and checks exact numbers — no dom, no
|
|
6
|
+
// rendering, no mocks, no randomness.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
activehitmarkers,
|
|
11
|
+
applydamage,
|
|
12
|
+
DAMAGESTRENGTHFLOOR,
|
|
13
|
+
directionaldamage,
|
|
14
|
+
effectivepool,
|
|
15
|
+
HITWINDOWS,
|
|
16
|
+
type Hitmarker,
|
|
17
|
+
healthfraction,
|
|
18
|
+
hitmarkerfor,
|
|
19
|
+
hitmarkerwindow,
|
|
20
|
+
isalive,
|
|
21
|
+
KILLFEEDCAP,
|
|
22
|
+
KILLFEEDTTL,
|
|
23
|
+
type KillfeedEntry,
|
|
24
|
+
killfeedline,
|
|
25
|
+
minimapbounds,
|
|
26
|
+
minimapconfig,
|
|
27
|
+
poolfraction,
|
|
28
|
+
prunekillfeed,
|
|
29
|
+
pushhitmarker,
|
|
30
|
+
pushkillfeed,
|
|
31
|
+
sectorof,
|
|
32
|
+
shieldfraction,
|
|
33
|
+
tominimap,
|
|
34
|
+
VITALTHRESHOLDS,
|
|
35
|
+
type Vitals,
|
|
36
|
+
vitaltier,
|
|
37
|
+
} from "../hud.ts";
|
|
38
|
+
|
|
39
|
+
/** full vitals: 100 health behind 50 shield. */
|
|
40
|
+
const FULL: Vitals = { health: 100, maxhealth: 100, shields: 50, maxshields: 50 };
|
|
41
|
+
|
|
42
|
+
/** a widget transform over a 100x100 world on a 200px map (10px rim). */
|
|
43
|
+
const WIDGET = { bounds: { minx: -50, minz: -50, maxx: 50, maxz: 50 }, sizepx: 200, padding: 10, yaw: 0 };
|
|
44
|
+
|
|
45
|
+
/** one killfeed row builder. */
|
|
46
|
+
function kill(id: string, at: number): KillfeedEntry {
|
|
47
|
+
return { id, killer: "ember-1", victim: "tide-2", weapon: "raven", at, headshot: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("vital bands", () => {
|
|
51
|
+
it("reads the pool fraction clamped to [0, 1]", () => {
|
|
52
|
+
assert.equal(poolfraction(FULL), 1);
|
|
53
|
+
assert.ok(Math.abs(poolfraction({ health: 40, maxhealth: 100, shields: 20, maxshields: 50 }) - 0.4) < 1e-9);
|
|
54
|
+
assert.equal(poolfraction({ health: 999, maxhealth: 100, shields: 999, maxshields: 50 }), 1);
|
|
55
|
+
assert.equal(poolfraction({ health: 0, maxhealth: 0, shields: 0, maxshields: 0 }), 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("bands the vitals through the four thresholds", () => {
|
|
59
|
+
assert.equal(vitaltier(FULL), "healthy");
|
|
60
|
+
assert.equal(vitaltier({ health: 70, maxhealth: 100, shields: 10, maxshields: 50 }), "wounded");
|
|
61
|
+
assert.equal(vitaltier({ health: 40, maxhealth: 100, shields: 10, maxshields: 50 }), "low");
|
|
62
|
+
assert.equal(vitaltier({ health: 20, maxhealth: 100, shields: 0, maxshields: 50 }), "critical");
|
|
63
|
+
assert.equal(VITALTHRESHOLDS.critical, 0.25);
|
|
64
|
+
assert.equal(VITALTHRESHOLDS.wounded, 0.75);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("applies damage with shields first and counts the overflow", () => {
|
|
68
|
+
const shielded = applydamage(FULL, 30);
|
|
69
|
+
assert.equal(shielded.absorbed, 30);
|
|
70
|
+
assert.deepEqual(shielded.vitals, { health: 100, maxhealth: 100, shields: 20, maxshields: 50 });
|
|
71
|
+
const spillover = applydamage({ health: 100, maxhealth: 100, shields: 20, maxshields: 50 }, 70);
|
|
72
|
+
assert.equal(spillover.absorbed, 20);
|
|
73
|
+
assert.equal(spillover.vitals.health, 50);
|
|
74
|
+
assert.equal(spillover.overflow, 0);
|
|
75
|
+
const lethal = applydamage({ health: 100, maxhealth: 100, shields: 0, maxshields: 50 }, 200);
|
|
76
|
+
assert.equal(lethal.vitals.health, 0);
|
|
77
|
+
assert.equal(lethal.overflow, 100);
|
|
78
|
+
assert.equal(applydamage(FULL, -5).overflow, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("answers life and the armor-perk effective pool", () => {
|
|
82
|
+
assert.equal(healthfraction(FULL), 1);
|
|
83
|
+
assert.ok(Math.abs(healthfraction({ ...FULL, health: 25 }) - 0.25) < 1e-9);
|
|
84
|
+
assert.ok(Math.abs(shieldfraction({ ...FULL, shields: 25 }) - 0.5) < 1e-9);
|
|
85
|
+
assert.equal(shieldfraction({ health: 1, maxhealth: 1, shields: 0, maxshields: 0 }), 0);
|
|
86
|
+
assert.equal(isalive(FULL), true);
|
|
87
|
+
assert.equal(isalive({ health: 0, maxhealth: 100, shields: 50, maxshields: 50 }), false);
|
|
88
|
+
assert.equal(effectivepool({ health: 50, maxhealth: 100, shields: 40, maxshields: 50 }, 0.5), 70);
|
|
89
|
+
assert.equal(effectivepool({ health: 50, maxhealth: 100, shields: 40, maxshields: 50 }, 2), 90);
|
|
90
|
+
assert.equal(effectivepool({ health: 50, maxhealth: 100, shields: 40, maxshields: 50 }, -1), 50);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("minimap transform", () => {
|
|
95
|
+
it("computes the padded bounds of a position cloud", () => {
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
minimapbounds(
|
|
98
|
+
[
|
|
99
|
+
{ x: 0, z: 10 },
|
|
100
|
+
{ x: 30, z: -10 },
|
|
101
|
+
],
|
|
102
|
+
5,
|
|
103
|
+
),
|
|
104
|
+
{ minx: -5, minz: -15, maxx: 35, maxz: 15 },
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(minimapbounds([], 5), { minx: -5, minz: -5, maxx: 5, maxz: 5 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("maps the world with facing up and z flipped into screen y", () => {
|
|
110
|
+
assert.deepEqual(tominimap(WIDGET, { x: 0, z: 0 }), { x: 90, y: 90, clamped: false });
|
|
111
|
+
assert.deepEqual(tominimap(WIDGET, { x: 0, z: 40 }), { x: 90, y: 18, clamped: false });
|
|
112
|
+
assert.deepEqual(tominimap(WIDGET, { x: 40, z: 0 }), { x: 162, y: 90, clamped: false });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rotates the world by the receiver yaw", () => {
|
|
116
|
+
const rotated = minimapconfig(WIDGET.bounds, WIDGET.sizepx, Math.PI / 2, WIDGET.padding);
|
|
117
|
+
assert.deepEqual(tominimap(rotated, { x: 40, z: 0 }), { x: 90, y: 18, clamped: false });
|
|
118
|
+
assert.deepEqual(tominimap(rotated, { x: 0, z: -40 }), { x: 162, y: 90, clamped: false });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clamps outside positions onto the rim with the flag", () => {
|
|
122
|
+
const rimmed = tominimap(WIDGET, { x: 100, z: 0 });
|
|
123
|
+
assert.equal(rimmed.x, 190);
|
|
124
|
+
assert.equal(rimmed.y, 90);
|
|
125
|
+
assert.equal(rimmed.clamped, true);
|
|
126
|
+
assert.equal(tominimap(WIDGET, { x: 10, z: 10 }).clamped, false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("hitmarkers", () => {
|
|
131
|
+
it("ranks the kinds and exposes their windows", () => {
|
|
132
|
+
assert.equal(hitmarkerfor(true, true), "kill");
|
|
133
|
+
assert.equal(hitmarkerfor(false, true), "headshot");
|
|
134
|
+
assert.equal(hitmarkerfor(false, false), "body");
|
|
135
|
+
assert.equal(hitmarkerwindow("body"), HITWINDOWS.body);
|
|
136
|
+
assert.equal(hitmarkerwindow("headshot"), HITWINDOWS.headshot);
|
|
137
|
+
assert.equal(hitmarkerwindow("kill"), HITWINDOWS.kill);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps only the markers still inside their window", () => {
|
|
141
|
+
const markers: Hitmarker[] = [
|
|
142
|
+
{ id: "m1", at: 0, kind: "body" },
|
|
143
|
+
{ id: "m2", at: 100, kind: "headshot" },
|
|
144
|
+
{ id: "m3", at: 300, kind: "kill" },
|
|
145
|
+
];
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
activehitmarkers(markers, 400).map((marker) => marker.id),
|
|
148
|
+
["m2", "m3"],
|
|
149
|
+
);
|
|
150
|
+
assert.equal(activehitmarkers(markers, 900).length, 0);
|
|
151
|
+
assert.equal(pushhitmarker(markers, { id: "m4", at: 500, kind: "body" }).length, 4);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("killfeed", () => {
|
|
156
|
+
it("dedups by id and caps the feed", () => {
|
|
157
|
+
const feed = pushkillfeed([], kill("k1", 100));
|
|
158
|
+
const refreshed = pushkillfeed(feed, { ...kill("k1", 150), victim: "tide-3" });
|
|
159
|
+
assert.equal(refreshed.length, 1);
|
|
160
|
+
assert.equal(refreshed[0].at, 150);
|
|
161
|
+
assert.equal(refreshed[0].victim, "tide-3");
|
|
162
|
+
let capped: KillfeedEntry[] = [];
|
|
163
|
+
for (let i = 0; i < 8; i++) capped = pushkillfeed(capped, kill(`k${i}`, i * 100));
|
|
164
|
+
assert.equal(capped.length, KILLFEEDCAP);
|
|
165
|
+
assert.equal(capped[0].id, "k2");
|
|
166
|
+
assert.equal(capped[capped.length - 1].id, "k7");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("expires rows past the ttl", () => {
|
|
170
|
+
const feed = [kill("k1", 1000), kill("k2", 7000)];
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
prunekillfeed(feed, 8500, KILLFEEDTTL).map((row) => row.id),
|
|
173
|
+
["k1", "k2"],
|
|
174
|
+
);
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
prunekillfeed(feed, 9100, KILLFEEDTTL).map((row) => row.id),
|
|
177
|
+
["k2"],
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("renders the plain feed line with the headshot mark", () => {
|
|
182
|
+
assert.equal(killfeedline(kill("k1", 0)), "ember-1 [raven] tide-2");
|
|
183
|
+
assert.equal(killfeedline({ ...kill("k1", 0), headshot: true }), "ember-1 [raven] tide-2 ⊕");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("directional damage", () => {
|
|
188
|
+
it("points front, right, behind and left relative to the yaw", () => {
|
|
189
|
+
assert.equal(directionaldamage({ x: 0, z: 20 }, { x: 0, z: 0 }, 0).sector, "front");
|
|
190
|
+
assert.equal(directionaldamage({ x: 20, z: 0 }, { x: 0, z: 0 }, 0).sector, "right");
|
|
191
|
+
assert.equal(directionaldamage({ x: 0, z: -20 }, { x: 0, z: 0 }, 0).sector, "behind");
|
|
192
|
+
assert.equal(directionaldamage({ x: -20, z: 0 }, { x: 0, z: 0 }, 0).sector, "left");
|
|
193
|
+
const faced = directionaldamage({ x: 20, z: 0 }, { x: 0, z: 0 }, Math.PI / 2);
|
|
194
|
+
assert.equal(faced.sector, "front");
|
|
195
|
+
assert.ok(Math.abs(faced.angle) < 1e-9);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("fades the ping strength with distance to the floor", () => {
|
|
199
|
+
assert.ok(Math.abs(directionaldamage({ x: 0, z: 20 }, { x: 0, z: 0 }, 0).strength - 0.5) < 1e-9);
|
|
200
|
+
assert.equal(directionaldamage({ x: 0, z: 200 }, { x: 0, z: 0 }, 0).strength, DAMAGESTRENGTHFLOOR);
|
|
201
|
+
assert.equal(sectorof(Math.PI), "behind");
|
|
202
|
+
assert.equal(sectorof((7 * Math.PI) / 4), "left");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// # matchrules.test — honest unit tests for the match rules state machine,
|
|
2
|
+
// runnable with the vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
|
+
// The fixtures reuse the match seed rows of the root match logic.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
|
+
import { MATCHPLAYERSEED, MATCHSEED, type MatchLobby } from "../match.ts";
|
|
8
|
+
import {
|
|
9
|
+
addscore,
|
|
10
|
+
advancelobby,
|
|
11
|
+
canlobbytransition,
|
|
12
|
+
canroundtransition,
|
|
13
|
+
matchruleserror,
|
|
14
|
+
nextactions,
|
|
15
|
+
openround,
|
|
16
|
+
progressof,
|
|
17
|
+
scoreround,
|
|
18
|
+
seatplayers,
|
|
19
|
+
transitionlobby,
|
|
20
|
+
} from "../matchrules.ts";
|
|
21
|
+
|
|
22
|
+
/** finds one seeded lobby by id (the tests key on the seed rows). */
|
|
23
|
+
function lobby(id: string): MatchLobby {
|
|
24
|
+
const row = MATCHSEED.find((candidate) => candidate.id === id);
|
|
25
|
+
assert.ok(row, `seed lobby ${id} missing`);
|
|
26
|
+
return row;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("match rules transitions", () => {
|
|
30
|
+
it("allows only the table transitions for lobbies", () => {
|
|
31
|
+
assert.equal(canlobbytransition("open", "live"), true);
|
|
32
|
+
assert.equal(canlobbytransition("open", "closed"), true);
|
|
33
|
+
assert.equal(canlobbytransition("live", "open"), false);
|
|
34
|
+
assert.equal(canlobbytransition("closed", "live"), false);
|
|
35
|
+
assert.equal(canlobbytransition("closed", "closed"), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("allows only pending -> live -> scored for rounds", () => {
|
|
39
|
+
assert.equal(canroundtransition("pending", "live"), true);
|
|
40
|
+
assert.equal(canroundtransition("live", "scored"), true);
|
|
41
|
+
assert.equal(canroundtransition("pending", "scored"), false);
|
|
42
|
+
assert.equal(canroundtransition("scored", "live"), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("moves a lobby and never mutates the input row", () => {
|
|
46
|
+
const open = lobby("m-2202");
|
|
47
|
+
const live = transitionlobby(open, "live");
|
|
48
|
+
assert.equal(live.state, "live");
|
|
49
|
+
assert.equal(open.state, "open");
|
|
50
|
+
assert.notEqual(live, open);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a forbidden lobby transition with a typed error", () => {
|
|
54
|
+
const closed = lobby("m-2204");
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => transitionlobby(closed, "live"),
|
|
57
|
+
(error: unknown) => {
|
|
58
|
+
assert.ok(error instanceof matchruleserror);
|
|
59
|
+
assert.equal(error.code, "bad-transition");
|
|
60
|
+
assert.equal(error.lobbyid, "m-2204");
|
|
61
|
+
assert.equal(error.from, "closed");
|
|
62
|
+
assert.equal(error.to, "live");
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("opens one pending round of a live lobby", () => {
|
|
69
|
+
const seeded = lobby("m-2202");
|
|
70
|
+
const live = transitionlobby(seeded, "live");
|
|
71
|
+
const running = openround(live, "r-4");
|
|
72
|
+
assert.equal(running.rounds.find((row) => row.id === "r-4")?.state, "live");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("refuses to open a round while another runs or the lobby is not live", () => {
|
|
76
|
+
const m2201 = lobby("m-2201");
|
|
77
|
+
assert.throws(() => openround(m2201, "r-3"), matchruleserror);
|
|
78
|
+
const live = transitionlobby(lobby("m-2202"), "live");
|
|
79
|
+
const running = openround(live, "r-4");
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => openround(running, "r-5"),
|
|
82
|
+
(error: unknown) => {
|
|
83
|
+
assert.ok(error instanceof matchruleserror);
|
|
84
|
+
assert.equal(error.code, "bad-transition");
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
assert.throws(() => openround(transitionlobby(lobby("m-2202"), "closed"), "r-4"), matchruleserror);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects an unknown round with the unknown-round code", () => {
|
|
92
|
+
const live = transitionlobby(lobby("m-2202"), "live");
|
|
93
|
+
assert.throws(
|
|
94
|
+
() => openround(live, "r-404"),
|
|
95
|
+
(error: unknown) => {
|
|
96
|
+
assert.ok(error instanceof matchruleserror);
|
|
97
|
+
assert.equal(error.code, "unknown-round");
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("scores the live round and the scored table rows stay scoreable by id", () => {
|
|
104
|
+
const live = transitionlobby(lobby("m-2202"), "live");
|
|
105
|
+
const running = openround(live, "r-4");
|
|
106
|
+
const done = scoreround(running);
|
|
107
|
+
assert.equal(done.rounds.find((row) => row.id === "r-4")?.state, "scored");
|
|
108
|
+
const reopened = openround(done, "r-5");
|
|
109
|
+
const scored = scoreround(reopened, "r-5");
|
|
110
|
+
assert.equal(scored.rounds.find((row) => row.id === "r-5")?.state, "scored");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("match rules capacity and scoring", () => {
|
|
115
|
+
it("seats players while the capacity holds and refuses overflow", () => {
|
|
116
|
+
const seated = lobby("m-2202");
|
|
117
|
+
const ok = seatplayers(
|
|
118
|
+
seated,
|
|
119
|
+
MATCHPLAYERSEED.filter((player) => player.lobbyid === "m-2202"),
|
|
120
|
+
);
|
|
121
|
+
assert.equal(ok.length, 2);
|
|
122
|
+
const overflow = [
|
|
123
|
+
...MATCHPLAYERSEED,
|
|
124
|
+
{ id: "p-99", lobbyid: "m-2202", handle: "overflow.seat", squad: "ash", score: 0, ping: 60 },
|
|
125
|
+
];
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => seatplayers(seated, overflow),
|
|
128
|
+
(error: unknown) => {
|
|
129
|
+
assert.ok(error instanceof matchruleserror);
|
|
130
|
+
assert.equal(error.code, "lobby-full");
|
|
131
|
+
return true;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("adds score, floors penalties at zero and refuses non finite deltas", () => {
|
|
137
|
+
const seat = MATCHPLAYERSEED[0];
|
|
138
|
+
assert.equal(addscore(seat, 120).score, seat.score + 120);
|
|
139
|
+
assert.equal(addscore({ ...seat, score: 40 }, -90).score, 0);
|
|
140
|
+
assert.throws(() => addscore(seat, Number.NaN), matchruleserror);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("match rules tick simulator", () => {
|
|
145
|
+
it("opens an open lobby on the first tick", () => {
|
|
146
|
+
const result = advancelobby(lobby("m-2202"), 0, { roundbudgetseconds: 300, autoadvance: false });
|
|
147
|
+
assert.equal(result.lobby.state, "live");
|
|
148
|
+
assert.deepEqual(
|
|
149
|
+
result.events.map((event) => event.kind),
|
|
150
|
+
["lobby-live"],
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("expires a live round past its budget and autoadvances the next one", () => {
|
|
155
|
+
const seeded = lobby("m-2202");
|
|
156
|
+
const first = advancelobby(seeded, 0, { roundbudgetseconds: 300, autoadvance: true });
|
|
157
|
+
assert.equal(first.lobby.rounds.find((row) => row.id === "r-4")?.state, "live");
|
|
158
|
+
const second = advancelobby(first.lobby, 301, { roundbudgetseconds: 300, autoadvance: true });
|
|
159
|
+
assert.equal(second.lobby.rounds.find((row) => row.id === "r-4")?.state, "scored");
|
|
160
|
+
assert.equal(second.lobby.rounds.find((row) => row.id === "r-5")?.state, "live");
|
|
161
|
+
assert.ok(second.events.some((event) => event.kind === "round-expired" && event.roundid === "r-4"));
|
|
162
|
+
assert.ok(second.events.some((event) => event.kind === "round-live" && event.roundid === "r-5"));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("closes the lobby when every round is scored", () => {
|
|
166
|
+
let current = transitionlobby(lobby("m-2202"), "live");
|
|
167
|
+
current = scoreround(openround(current, "r-4"));
|
|
168
|
+
current = scoreround(openround(current, "r-5"));
|
|
169
|
+
const ticked = advancelobby(current, 0, { roundbudgetseconds: 300, autoadvance: true });
|
|
170
|
+
assert.equal(ticked.lobby.state, "closed");
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
ticked.events.map((event) => event.kind),
|
|
173
|
+
["lobby-closed"],
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("keeps a live round inside its budget untouched", () => {
|
|
178
|
+
const seeded = lobby("m-2202");
|
|
179
|
+
const first = advancelobby(seeded, 0, { roundbudgetseconds: 300, autoadvance: true });
|
|
180
|
+
const second = advancelobby(first.lobby, 120, { roundbudgetseconds: 300, autoadvance: true });
|
|
181
|
+
assert.equal(second.lobby.state, "live");
|
|
182
|
+
assert.equal(second.lobby.rounds.find((row) => row.id === "r-4")?.state, "live");
|
|
183
|
+
assert.equal(second.events.length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("refuses a bad clock or a bad budget", () => {
|
|
187
|
+
assert.throws(
|
|
188
|
+
() => advancelobby(lobby("m-2202"), -1, { roundbudgetseconds: 300, autoadvance: true }),
|
|
189
|
+
matchruleserror,
|
|
190
|
+
);
|
|
191
|
+
assert.throws(
|
|
192
|
+
() => advancelobby(lobby("m-2202"), 10, { roundbudgetseconds: 0, autoadvance: true }),
|
|
193
|
+
matchruleserror,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("match rules board hints", () => {
|
|
199
|
+
it("hints the board actions in lifecycle order", () => {
|
|
200
|
+
assert.deepEqual(nextactions(lobby("m-2202")), ["start the match"]);
|
|
201
|
+
assert.deepEqual(nextactions(lobby("m-2201")), ["score the live round"]);
|
|
202
|
+
assert.deepEqual(nextactions(lobby("m-2204")), []);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("hints the close action for a live lobby with everything scored", () => {
|
|
206
|
+
const allscored: MatchLobby = {
|
|
207
|
+
...lobby("m-2202"),
|
|
208
|
+
state: "live",
|
|
209
|
+
rounds: lobby("m-2202").rounds.map((row) => ({ ...row, state: "scored" })),
|
|
210
|
+
};
|
|
211
|
+
assert.deepEqual(nextactions(allscored), ["close the lobby"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("measures the scored progress of a lobby", () => {
|
|
215
|
+
assert.equal(progressof(lobby("m-2201")), 33);
|
|
216
|
+
assert.equal(progressof(lobby("m-2204")), 100);
|
|
217
|
+
assert.equal(progressof({ ...lobby("m-2203"), rounds: [] }), 0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// # netcode.test — honest unit tests for the deterministic netcode layer
|
|
2
|
+
// (deltas, interpolation, reconciliation, rewind, jitter), runnable with
|
|
3
|
+
// the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// Every case feeds plain snapshots and commands and checks exact output —
|
|
6
|
+
// no sockets, no mocks, no timing, no randomness.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
applydelta,
|
|
11
|
+
bracketpair,
|
|
12
|
+
CMDSPEED,
|
|
13
|
+
DEFAULTINTERPDELAY,
|
|
14
|
+
duepackets,
|
|
15
|
+
type EntityState,
|
|
16
|
+
entityattick,
|
|
17
|
+
interpolateat,
|
|
18
|
+
jitterof,
|
|
19
|
+
latestsnapshot,
|
|
20
|
+
lerpentity,
|
|
21
|
+
makedelta,
|
|
22
|
+
makejitterbuffer,
|
|
23
|
+
needsreconcile,
|
|
24
|
+
positionerror,
|
|
25
|
+
pushpacket,
|
|
26
|
+
pushrewind,
|
|
27
|
+
RECONCILETHRESHOLD,
|
|
28
|
+
reconcile,
|
|
29
|
+
replaycmds,
|
|
30
|
+
rewindattick,
|
|
31
|
+
type Snapshot,
|
|
32
|
+
simulatecmd,
|
|
33
|
+
wrapangle,
|
|
34
|
+
yawlerp,
|
|
35
|
+
} from "../netcode.ts";
|
|
36
|
+
|
|
37
|
+
/** one entity standing at a spot. */
|
|
38
|
+
function entityat(id: string, px: number, tick: number, yaw = 0): EntityState {
|
|
39
|
+
return { id, tick, px, py: 0, pz: 0, yaw };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** one snapshot from id/px pairs. */
|
|
43
|
+
function snapshot(tick: number, time: number, rows: Array<[string, number]>): Snapshot {
|
|
44
|
+
return { tick, time, entities: rows.map(([id, px]) => entityat(id, px, tick)) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("angles", () => {
|
|
48
|
+
it("wraps angles into [0, 2π)", () => {
|
|
49
|
+
assert.ok(Math.abs(wrapangle(-0.1) - (Math.PI * 2 - 0.1)) < 1e-9);
|
|
50
|
+
assert.ok(Math.abs(wrapangle(Math.PI * 5) - Math.PI) < 1e-9);
|
|
51
|
+
assert.equal(wrapangle(0), 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("interpolates yaw by the shortest arc", () => {
|
|
55
|
+
assert.ok(Math.abs(yawlerp(0, Math.PI / 2, 0.5) - Math.PI / 4) < 1e-9);
|
|
56
|
+
assert.ok(Math.abs(yawlerp(3, -3, 0.5) - Math.PI) < 1e-9);
|
|
57
|
+
assert.ok(Math.abs(yawlerp(1, 1, 0.9) - 1) < 1e-9);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("snapshot deltas", () => {
|
|
62
|
+
it("carries only movers, spawns and despawns", () => {
|
|
63
|
+
const base = snapshot(1, 10, [
|
|
64
|
+
["a", 0],
|
|
65
|
+
["b", 5],
|
|
66
|
+
["gone", 9],
|
|
67
|
+
]);
|
|
68
|
+
const next = snapshot(2, 20, [
|
|
69
|
+
["a", 4],
|
|
70
|
+
["b", 5],
|
|
71
|
+
["new", 1],
|
|
72
|
+
]);
|
|
73
|
+
const delta = makedelta(base, next);
|
|
74
|
+
assert.equal(delta.base, 1);
|
|
75
|
+
assert.deepEqual(delta.changed.map((entity) => entity.id).sort(), ["a", "new"]);
|
|
76
|
+
assert.deepEqual(delta.removed, ["gone"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rebuilds the full snapshot from base plus delta", () => {
|
|
80
|
+
const base = snapshot(1, 10, [
|
|
81
|
+
["a", 0],
|
|
82
|
+
["gone", 9],
|
|
83
|
+
]);
|
|
84
|
+
const next = snapshot(2, 20, [
|
|
85
|
+
["a", 4],
|
|
86
|
+
["new", 1],
|
|
87
|
+
]);
|
|
88
|
+
const rebuilt = applydelta(base, makedelta(base, next));
|
|
89
|
+
assert.equal(rebuilt.tick, 2);
|
|
90
|
+
assert.deepEqual(rebuilt.entities.map((entity) => entity.id).sort(), ["a", "new"]);
|
|
91
|
+
assert.equal(rebuilt.entities.find((entity) => entity.id === "a")?.px, 4);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("answers the newest snapshot by tick", () => {
|
|
95
|
+
const stream = [snapshot(1, 0, []), snapshot(3, 20, []), snapshot(2, 10, [])];
|
|
96
|
+
assert.equal(latestsnapshot(stream)?.tick, 3);
|
|
97
|
+
assert.equal(latestsnapshot([]), null);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("entity interpolation", () => {
|
|
102
|
+
it("brackets the render time without extrapolating", () => {
|
|
103
|
+
const stream = [snapshot(1, 0, [["a", 0]]), snapshot(2, 100, [["a", 10]])];
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
bracketpair(stream, 50)?.map((snap) => snap.tick),
|
|
106
|
+
[1, 2],
|
|
107
|
+
);
|
|
108
|
+
assert.equal(bracketpair(stream, 150), null);
|
|
109
|
+
assert.equal(bracketpair(stream, -1), null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("interpolates positions and yaw at the fraction", () => {
|
|
113
|
+
const stream = [snapshot(1, 0, [["a", 0]]), snapshot(2, 100, [["a", 10]])];
|
|
114
|
+
const state = interpolateat(stream, 50).find((entity) => entity.id === "a");
|
|
115
|
+
assert.equal(state?.px, 5);
|
|
116
|
+
const turn = interpolateat(
|
|
117
|
+
[snapshot(1, 0, [["a", 0]]), snapshot(2, 100, [["a", 10]])].map((snap, i) => ({
|
|
118
|
+
...snap,
|
|
119
|
+
entities: snap.entities.map((entity) => ({ ...entity, yaw: i === 0 ? 0 : Math.PI })),
|
|
120
|
+
})),
|
|
121
|
+
50,
|
|
122
|
+
);
|
|
123
|
+
assert.ok(Math.abs((turn.find((entity) => entity.id === "a")?.yaw ?? 0) - Math.PI / 2) < 1e-9);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("spawns newer-only entities and drops older-only ones", () => {
|
|
127
|
+
const stream = [
|
|
128
|
+
snapshot(1, 0, [
|
|
129
|
+
["a", 0],
|
|
130
|
+
["dead", 9],
|
|
131
|
+
]),
|
|
132
|
+
snapshot(2, 100, [
|
|
133
|
+
["a", 10],
|
|
134
|
+
["born", 3],
|
|
135
|
+
]),
|
|
136
|
+
];
|
|
137
|
+
const states = interpolateat(stream, 50);
|
|
138
|
+
assert.deepEqual(states.map((entity) => entity.id).sort(), ["a", "born"]);
|
|
139
|
+
assert.equal(states.find((entity) => entity.id === "born")?.px, 3);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("lerps one entity directly", () => {
|
|
143
|
+
const out = lerpentity(entityat("a", 0, 1, 0), { ...entityat("a", 10, 2, Math.PI), py: 4 }, 0.25);
|
|
144
|
+
assert.equal(out.px, 2.5);
|
|
145
|
+
assert.equal(out.py, 1);
|
|
146
|
+
assert.ok(Math.abs(out.yaw - Math.PI / 4) < 1e-9);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("prediction and reconciliation", () => {
|
|
151
|
+
it("advances the entity with the shared movement math", () => {
|
|
152
|
+
const walked = simulatecmd(entityat("a", 0, 1), { seq: 1, forward: 1, side: 0, yaw: 0, dt: 0.5 });
|
|
153
|
+
assert.equal(walked.px, CMDSPEED * 0.5);
|
|
154
|
+
assert.equal(walked.tick, 2);
|
|
155
|
+
const sideways = simulatecmd(entityat("a", 0, 1), { seq: 2, forward: 0, side: 1, yaw: 0, dt: 1 });
|
|
156
|
+
assert.equal(sideways.pz, -CMDSPEED);
|
|
157
|
+
const turned = simulatecmd(entityat("a", 0, 1), { seq: 3, forward: 1, side: 0, yaw: Math.PI / 2, dt: 1 });
|
|
158
|
+
assert.ok(Math.abs(turned.pz - CMDSPEED) < 1e-9);
|
|
159
|
+
assert.equal(turned.yaw, Math.PI / 2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("replays command lists in order", () => {
|
|
163
|
+
const cmds = [
|
|
164
|
+
{ seq: 1, forward: 1, side: 0, yaw: 0, dt: 1 },
|
|
165
|
+
{ seq: 2, forward: 1, side: 0, yaw: 0, dt: 1 },
|
|
166
|
+
];
|
|
167
|
+
assert.equal(replaycmds(entityat("a", 0, 1), cmds).px, 2 * CMDSPEED);
|
|
168
|
+
assert.equal(replaycmds(entityat("a", 0, 1), []).px, 0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("reconciles only above the drift threshold", () => {
|
|
172
|
+
const server = entityat("a", 3, 5);
|
|
173
|
+
const pending = [{ seq: 1, forward: 1, side: 0, yaw: 0, dt: 0.5 }];
|
|
174
|
+
const drifted = reconcile(entityat("a", 10, 7), server, pending, RECONCILETHRESHOLD);
|
|
175
|
+
assert.ok(drifted.error > RECONCILETHRESHOLD);
|
|
176
|
+
assert.equal(drifted.replayed.length, 1);
|
|
177
|
+
assert.equal(drifted.state.px, 3 + CMDSPEED * 0.5);
|
|
178
|
+
const close = reconcile(entityat("a", 3, 5), server, pending, RECONCILETHRESHOLD);
|
|
179
|
+
assert.equal(close.replayed.length, 0);
|
|
180
|
+
assert.equal(close.state.px, 3);
|
|
181
|
+
assert.equal(needsreconcile(entityat("a", 3.05, 5), server, RECONCILETHRESHOLD), false);
|
|
182
|
+
assert.equal(needsreconcile(entityat("a", 3.2, 5), server, RECONCILETHRESHOLD), true);
|
|
183
|
+
assert.ok(Math.abs(positionerror({ ...entityat("a", 3, 5), pz: 4 }, server) - 4) < 1e-9);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("lag compensation rewind", () => {
|
|
188
|
+
it("keeps the history ordered and capped to the newest frames", () => {
|
|
189
|
+
let buffer = { frames: [] as Snapshot[], capacity: 2 };
|
|
190
|
+
for (const tick of [10, 20, 30]) buffer = pushrewind(buffer, snapshot(tick, tick * 10, [["a", tick]]));
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
buffer.frames.map((frame) => frame.tick),
|
|
193
|
+
[20, 30],
|
|
194
|
+
);
|
|
195
|
+
buffer = pushrewind(buffer, snapshot(20, 200, [["a", 20]]));
|
|
196
|
+
assert.deepEqual(
|
|
197
|
+
buffer.frames.map((frame) => frame.tick),
|
|
198
|
+
[20, 30],
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("rewinds to the newest frame at or before the tick", () => {
|
|
203
|
+
let buffer = { frames: [] as Snapshot[], capacity: 8 };
|
|
204
|
+
for (const tick of [10, 20, 30]) buffer = pushrewind(buffer, snapshot(tick, tick * 10, [["a", tick]]));
|
|
205
|
+
assert.equal(rewindattick(buffer, 25)?.tick, 20);
|
|
206
|
+
assert.equal(rewindattick(buffer, 30)?.tick, 30);
|
|
207
|
+
assert.equal(rewindattick(buffer, 5), null);
|
|
208
|
+
assert.equal(entityattick(buffer, 25, "a")?.px, 20);
|
|
209
|
+
assert.equal(entityattick(buffer, 25, "ghost"), null);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("jitter buffer", () => {
|
|
214
|
+
it("measures the arrival jitter of the stream", () => {
|
|
215
|
+
assert.equal(jitterof([0, 20, 45, 70]), 2.5);
|
|
216
|
+
assert.equal(jitterof([0, 10]), 0);
|
|
217
|
+
assert.equal(jitterof([5]), 0);
|
|
218
|
+
assert.equal(jitterof([]), 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("orders, dedupes and caps the playout queue", () => {
|
|
222
|
+
let buffer = makejitterbuffer(2, DEFAULTINTERPDELAY);
|
|
223
|
+
buffer = pushpacket(buffer, { tick: 2, arrivedat: 20 });
|
|
224
|
+
buffer = pushpacket(buffer, { tick: 1, arrivedat: 10 });
|
|
225
|
+
buffer = pushpacket(buffer, { tick: 1, arrivedat: 11 });
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
buffer.packets.map((packet) => packet.tick),
|
|
228
|
+
[1, 2],
|
|
229
|
+
);
|
|
230
|
+
buffer = pushpacket(buffer, { tick: 3, arrivedat: 30 });
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
buffer.packets.map((packet) => packet.tick),
|
|
233
|
+
[2, 3],
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("drains only the packets whose playout delay elapsed", () => {
|
|
238
|
+
let buffer = makejitterbuffer(8, 50);
|
|
239
|
+
buffer = pushpacket(buffer, { tick: 1, arrivedat: 0 });
|
|
240
|
+
buffer = pushpacket(buffer, { tick: 2, arrivedat: 40 });
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
duepackets(buffer, 60).map((packet) => packet.tick),
|
|
243
|
+
[1],
|
|
244
|
+
);
|
|
245
|
+
assert.deepEqual(
|
|
246
|
+
duepackets(buffer, 100).map((packet) => packet.tick),
|
|
247
|
+
[1, 2],
|
|
248
|
+
);
|
|
249
|
+
assert.deepEqual(duepackets(buffer, 10), []);
|
|
250
|
+
});
|
|
251
|
+
});
|