@wenathlan/stealthhead 0.0.0-stage → 2.0.94

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.
@@ -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
+ });
@@ -0,0 +1,185 @@
1
+ // # physics.test — honest unit tests for the deterministic physics kernel,
2
+ // runnable with the vitest runner:
3
+ // pnpm test
4
+ // Every case feeds plain structs and checks the exact replayable output —
5
+ // no fixtures from the DOM, no randomness, no mocks.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import {
9
+ aabb,
10
+ aabbcenter,
11
+ add,
12
+ applydrag,
13
+ type Body,
14
+ boxboxhit,
15
+ closestpoint,
16
+ createworld,
17
+ DEFAULTGRAVITY,
18
+ distance,
19
+ dot,
20
+ fixedsteps,
21
+ length,
22
+ normalize,
23
+ type PhysWorld,
24
+ scale,
25
+ sphereboxhit,
26
+ spherespherehit,
27
+ stepbody,
28
+ stepprojectile,
29
+ sweepbox,
30
+ vec3,
31
+ } from "../physics.ts";
32
+
33
+ const UNIT = aabb(vec3(0, 0, 0), vec3(1, 1, 1));
34
+
35
+ /** one free body resting at the origin with standard tuning. */
36
+ function bodyat(x: number, y: number, z: number, velocity = vec3(0, 0, 0)): Body {
37
+ return { id: "b1", position: vec3(x, y, z), velocity, invmass: 1, restitution: 0.5, friction: 0 };
38
+ }
39
+
40
+ /** an open arena from the origin to 100 on every axis. */
41
+ function arena(): PhysWorld {
42
+ return createworld(vec3(0, 0, 0), vec3(100, 100, 100));
43
+ }
44
+
45
+ describe("vector kernel", () => {
46
+ it("adds, scales, dots, measures and normalizes without mutating", () => {
47
+ const v = vec3(3, 0, 4);
48
+ assert.deepEqual(add(v, vec3(1, 2, 3)), { x: 4, y: 2, z: 7 });
49
+ assert.deepEqual(scale(v, 2), { x: 6, y: 0, z: 8 });
50
+ assert.equal(dot(v, vec3(1, 0, 0)), 3);
51
+ assert.equal(length(v), 5);
52
+ const unit = normalize(v);
53
+ assert.ok(Math.abs(unit.x - 0.6) < 1e-9);
54
+ assert.equal(unit.y, 0);
55
+ assert.ok(Math.abs(unit.z - 0.8) < 1e-9);
56
+ assert.equal(length(v), 5);
57
+ });
58
+
59
+ it("keeps normalize total on the zero vector", () => {
60
+ assert.deepEqual(normalize(vec3(0, 0, 0)), { x: 0, y: 0, z: 0 });
61
+ assert.equal(distance(vec3(1, 2, 3), vec3(4, 6, 3)), 5);
62
+ });
63
+ });
64
+
65
+ describe("shape queries", () => {
66
+ it("builds boxes, centers them and finds closest points", () => {
67
+ const box = aabb(vec3(0, 0, 0), vec3(4, 2, 6));
68
+ assert.deepEqual(aabbcenter(box), { x: 2, y: 1, z: 3 });
69
+ assert.deepEqual(closestpoint(box, vec3(5, 1, 3)), { x: 4, y: 1, z: 3 });
70
+ assert.deepEqual(closestpoint(UNIT, vec3(0.5, 2, -1)), { x: 0.5, y: 1, z: 0 });
71
+ });
72
+
73
+ it("answers sphere, sphere-sphere and box-box overlaps", () => {
74
+ assert.equal(sphereboxhit({ center: vec3(2, 0.5, 0.5), radius: 1.5 }, UNIT), true);
75
+ assert.equal(sphereboxhit({ center: vec3(2, 0.5, 0.5), radius: 0.5 }, UNIT), false);
76
+ assert.equal(spherespherehit({ center: vec3(0, 0, 0), radius: 1 }, { center: vec3(2, 0, 0), radius: 1 }), true);
77
+ assert.equal(spherespherehit({ center: vec3(0, 0, 0), radius: 0.9 }, { center: vec3(2, 0, 0), radius: 1 }), false);
78
+ assert.equal(boxboxhit(UNIT, aabb(vec3(0.5, 0.5, 0.5), vec3(2, 2, 2))), true);
79
+ assert.equal(boxboxhit(UNIT, aabb(vec3(2, 2, 2), vec3(3, 3, 3))), false);
80
+ });
81
+
82
+ it("sweeps a box and reports the entry fraction or null", () => {
83
+ const wall = aabb(vec3(4, 0, 0), vec3(5, 1, 1));
84
+ assert.equal(sweepbox(UNIT, vec3(4, 0, 0), wall), 0.75);
85
+ assert.equal(sweepbox(UNIT, vec3(3, 0, 0), wall), 1);
86
+ assert.equal(sweepbox(UNIT, vec3(4, 0, 0), aabb(vec3(4, 5, 0), vec3(5, 6, 1))), null);
87
+ assert.equal(sweepbox(UNIT, vec3(0, 0, 0), aabb(vec3(0.2, 0.2, 0.2), vec3(0.8, 0.8, 0.8))), 0);
88
+ assert.equal(sweepbox(UNIT, vec3(0, 0, 0), aabb(vec3(5, 5, 5), vec3(6, 6, 6))), null);
89
+ });
90
+ });
91
+
92
+ describe("integration", () => {
93
+ it("applies gravity and bounces on the arena floor with restitution", () => {
94
+ const next = stepbody(
95
+ bodyat(0, 0, 0, vec3(1, 0, 0)),
96
+ 1,
97
+ vec3(0, -10, 0),
98
+ aabb(vec3(-10, 0, -10), vec3(10, 10, 10)),
99
+ );
100
+ assert.deepEqual(next.position, { x: 1, y: 0, z: 0 });
101
+ assert.equal(next.velocity.x, 1);
102
+ assert.equal(next.velocity.y, 5);
103
+ });
104
+
105
+ it("damps horizontal velocity by friction and respects invmass 0", () => {
106
+ const grounded = { ...bodyat(0, 0, 0, vec3(2, 0, 0)), friction: 1 };
107
+ const next = stepbody(grounded, 1, vec3(0, -10, 0), aabb(vec3(-10, 0, -10), vec3(10, 10, 10)));
108
+ assert.equal(next.velocity.x, 0);
109
+ const pinned: Body = { ...bodyat(3, 3, 3, vec3(1, 1, 1)), invmass: 0 };
110
+ const still = stepbody(pinned, 1, vec3(0, -10, 0), aabb(vec3(-10, 0, -10), vec3(10, 10, 10)));
111
+ assert.deepEqual(still.position, { x: 3, y: 3, z: 3 });
112
+ assert.deepEqual(still.velocity, { x: 1, y: 1, z: 1 });
113
+ });
114
+
115
+ it("decays velocity through linear drag", () => {
116
+ assert.deepEqual(applydrag(vec3(10, 0, 0), 0.5, 0.2), { x: 9, y: 0, z: 0 });
117
+ assert.deepEqual(applydrag(vec3(10, 0, 0), 1, 2), { x: 0, y: 0, z: 0 });
118
+ });
119
+ });
120
+
121
+ describe("projectiles", () => {
122
+ it("flies with gravity and drag and reports no hit in open air", () => {
123
+ const step = stepprojectile(
124
+ { id: "p1", position: vec3(0, 50, 50), velocity: vec3(10, 0, 0), gravity: -10, drag: 0 },
125
+ 1,
126
+ arena(),
127
+ );
128
+ assert.deepEqual(step.projectile.position, { x: 10, y: 40, z: 50 });
129
+ assert.equal(step.hit, null);
130
+ const dragged = stepprojectile(
131
+ { id: "p2", position: vec3(0, 50, 50), velocity: vec3(10, 0, 0), gravity: 0, drag: 0.5 },
132
+ 1,
133
+ arena(),
134
+ );
135
+ assert.equal(dragged.projectile.velocity.x, 5);
136
+ });
137
+
138
+ it("stops at the arena bounds as a world hit", () => {
139
+ const step = stepprojectile(
140
+ { id: "p3", position: vec3(99, 50, 50), velocity: vec3(10, 0, 0), gravity: -10, drag: 0 },
141
+ 1,
142
+ arena(),
143
+ );
144
+ assert.equal(step.hit?.kind, "world");
145
+ assert.deepEqual(step.hit?.at, { x: 100, y: 40, z: 50 });
146
+ });
147
+
148
+ it("stops at the first swept body hit and names it", () => {
149
+ const world = arena();
150
+ world.bodies.push({ ...bodyat(5, 50, 50), id: "crate" });
151
+ const step = stepprojectile(
152
+ { id: "p4", position: vec3(0, 50, 50), velocity: vec3(10, 0, 0), gravity: 0, drag: 0 },
153
+ 1,
154
+ world,
155
+ );
156
+ assert.equal(step.hit?.kind, "body");
157
+ assert.equal(step.hit?.id, "crate");
158
+ assert.deepEqual(step.hit?.at, { x: 5, y: 50, z: 50 });
159
+ });
160
+ });
161
+
162
+ describe("fixed-step loop", () => {
163
+ it("converts frame deltas into whole steps plus a carried remainder", () => {
164
+ const first = fixedsteps(0, 0.05, 0.02);
165
+ assert.equal(first.steps, 2);
166
+ assert.ok(Math.abs(first.remainder - 0.01) < 1e-9);
167
+ const second = fixedsteps(0.01, 0.02, 0.02);
168
+ assert.equal(second.steps, 1);
169
+ assert.ok(Math.abs(second.remainder - 0.01) < 1e-9);
170
+ });
171
+
172
+ it("caps catch-up steps so a stall never spirals", () => {
173
+ const capped = fixedsteps(0, 1, 0.02, 3);
174
+ assert.equal(capped.steps, 3);
175
+ assert.ok(Math.abs(capped.remainder - 0.94) < 1e-9);
176
+ });
177
+
178
+ it("bootstraps the empty arena with the default gravity", () => {
179
+ const world = createworld(vec3(0, 0, 0), vec3(10, 10, 10));
180
+ assert.deepEqual(world.gravity, DEFAULTGRAVITY);
181
+ assert.equal(world.bodies.length, 0);
182
+ assert.equal(world.projectiles.length, 0);
183
+ assert.equal(dot(DEFAULTGRAVITY, vec3(0, 1, 0)), -19.6);
184
+ });
185
+ });
@@ -0,0 +1,162 @@
1
+ // # rankingladder.test — honest unit tests for the elo ladder arithmetic,
2
+ // runnable with the vitest runner:
3
+ // pnpm test
4
+ // Every k table, ceiling and floor arrives as a parameter — the tests pass
5
+ // their own season config, exactly as a caller would.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import {
9
+ applydelta,
10
+ expectedscore,
11
+ kfactor,
12
+ type LadderOptions,
13
+ laddererror,
14
+ projectedrating,
15
+ rankduel,
16
+ ratingdelta,
17
+ } from "../rankingladder.ts";
18
+
19
+ /** the season config the tests play with: 32/24/16 bands like the classic tables. */
20
+ const SEASON: LadderOptions = {
21
+ ktable: [
22
+ { minrating: 0, maxrating: 2100, k: 32 },
23
+ { minrating: 2100, maxrating: 2400, k: 24 },
24
+ { minrating: 2400, maxrating: Number.POSITIVE_INFINITY, k: 16 },
25
+ ],
26
+ maxdelta: 40,
27
+ floorrating: 100,
28
+ scale: 400,
29
+ };
30
+
31
+ describe("ranking ladder expected score", () => {
32
+ it("answers one half for equal ratings", () => {
33
+ assert.equal(expectedscore(1500, 1500, SEASON), 0.5);
34
+ });
35
+
36
+ it("answers the classic 0.909 for a 400 point favorite", () => {
37
+ const expected = expectedscore(1900, 1500, SEASON);
38
+ assert.ok(Math.abs(expected - 0.909) < 0.001, `expected ~0.909, got ${expected}`);
39
+ assert.ok(expectedscore(1500, 1900, SEASON) < 0.1);
40
+ });
41
+
42
+ it("mirrors around one half", () => {
43
+ const up = expectedscore(1700, 1500, SEASON);
44
+ const down = expectedscore(1500, 1700, SEASON);
45
+ assert.ok(Math.abs(up + down - 1) < 1e-9);
46
+ });
47
+
48
+ it("refuses non finite ratings", () => {
49
+ assert.throws(
50
+ () => expectedscore(Number.NaN, 1500, SEASON),
51
+ (error: unknown) => {
52
+ assert.ok(error instanceof laddererror);
53
+ assert.equal(error.code, "bad-rating");
54
+ return true;
55
+ },
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("ranking ladder k factor", () => {
61
+ it("picks the band the rating falls into", () => {
62
+ assert.equal(kfactor(1500, SEASON.ktable), 32);
63
+ assert.equal(kfactor(2200, SEASON.ktable), 24);
64
+ assert.equal(kfactor(2500, SEASON.ktable), 16);
65
+ assert.equal(kfactor(2399.9, SEASON.ktable), 24);
66
+ });
67
+
68
+ it("serves a different table when the season passes one", () => {
69
+ assert.equal(kfactor(1500, [{ minrating: 0, maxrating: Number.POSITIVE_INFINITY, k: 10 }]), 10);
70
+ });
71
+
72
+ it("refuses an empty table, a non positive k and an uncovered rating", () => {
73
+ assert.throws(
74
+ () => kfactor(1500, []),
75
+ (error: unknown) => {
76
+ assert.ok(error instanceof laddererror);
77
+ assert.equal(error.code, "bad-ktable");
78
+ return true;
79
+ },
80
+ );
81
+ assert.throws(() => kfactor(1500, [{ minrating: 0, maxrating: 1000, k: 32 }]), laddererror);
82
+ assert.throws(() => kfactor(1500, [{ minrating: 0, maxrating: Number.POSITIVE_INFINITY, k: 0 }]), laddererror);
83
+ });
84
+ });
85
+
86
+ describe("ranking ladder deltas", () => {
87
+ it("rewards a win by k * (1 - expected) and penalizes the mirrored loss", () => {
88
+ const win = ratingdelta(1500, 1500, "win", SEASON);
89
+ assert.equal(win, 16); // k 32, expected 0.5
90
+ const loss = ratingdelta(1500, 1500, "loss", SEASON);
91
+ assert.equal(loss, -16);
92
+ });
93
+
94
+ it("pays less to the favorite and more to the underdog", () => {
95
+ const favorite = ratingdelta(1900, 1500, "win", SEASON);
96
+ const underdog = ratingdelta(1500, 1900, "win", SEASON);
97
+ assert.ok(favorite > 0 && favorite < 4, `favorite should earn little, got ${favorite}`);
98
+ assert.ok(underdog > 28, `underdog should earn a lot, got ${underdog}`);
99
+ });
100
+
101
+ it("scales with the k table the caller passes", () => {
102
+ const low = ratingdelta(1500, 1500, "win", {
103
+ ...SEASON,
104
+ ktable: [{ minrating: 0, maxrating: Number.POSITIVE_INFINITY, k: 8 }],
105
+ });
106
+ assert.equal(low, 4);
107
+ });
108
+
109
+ it("clamps the delta to the season ceiling", () => {
110
+ const clamped = ratingdelta(1500, 3500, "win", { ...SEASON, maxdelta: 20 });
111
+ assert.ok(clamped <= 20, `expected the clamp to hold, got ${clamped}`);
112
+ const floored = ratingdelta(3500, 900, "loss", { ...SEASON, maxdelta: 20 });
113
+ assert.ok(floored >= -20, `expected the clamp to hold, got ${floored}`);
114
+ });
115
+
116
+ it("grades a draw as half a win against an equal opponent", () => {
117
+ assert.equal(ratingdelta(1500, 1500, "draw", SEASON), 0);
118
+ assert.ok(ratingdelta(1500, 1900, "draw", SEASON) > 0);
119
+ });
120
+
121
+ it("refuses an unknown outcome", () => {
122
+ assert.throws(
123
+ () => ratingdelta(1500, 1500, "forfeit" as "win", SEASON),
124
+ (error: unknown) => {
125
+ assert.ok(error instanceof laddererror);
126
+ assert.equal(error.code, "bad-outcome");
127
+ return true;
128
+ },
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("ranking ladder duels and projections", () => {
134
+ it("applies the floor when a big loss sinks the rating", () => {
135
+ assert.equal(applydelta(120, -90, 100), 100);
136
+ assert.equal(applydelta(1500, -30, 100), 1470);
137
+ });
138
+
139
+ it("rates both sides of a duel with mirrored deltas", () => {
140
+ const result = rankduel({ id: "vanta.k", rating: 1600 }, { id: "coldsnap", rating: 1400 }, "win", SEASON);
141
+ assert.ok(result.player.delta > 0 && result.opponent.delta < 0);
142
+ assert.ok(Math.abs(result.expected - 0.76) < 0.001, `expected ~0.760, got ${result.expected}`);
143
+ assert.equal(result.player.rating, 1600 + result.player.delta);
144
+ assert.equal(result.opponent.rating, 1400 + result.opponent.delta);
145
+ });
146
+
147
+ it("compounds a winning streak through the running rating", () => {
148
+ const opponents = [1500, 1500, 1500, 1500];
149
+ const after = projectedrating(
150
+ 1500,
151
+ opponents.map((opponent) => ({ opponent, outcome: "win" as const })),
152
+ SEASON,
153
+ );
154
+ assert.ok(after > 1550, `a streak should compound, got ${after}`);
155
+ const recovered = projectedrating(
156
+ after,
157
+ opponents.map((opponent) => ({ opponent, outcome: "loss" as const })),
158
+ SEASON,
159
+ );
160
+ assert.ok(recovered < after);
161
+ });
162
+ });