@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,62 @@
|
|
|
1
|
+
// # onboardingstate.test — the pure contract of the seen flags: the host
|
|
2
|
+
// validation of the platform base (https only, public hosts only) and the
|
|
3
|
+
// seen-wins stage merge the accessor and the pages share. runnable with
|
|
4
|
+
// vitest: pnpm vitest run tests/onboardingstate.test.ts
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
|
+
import { hostallowed, mergeStage } from "../onboardingstate.ts";
|
|
8
|
+
|
|
9
|
+
describe("onboarding state", () => {
|
|
10
|
+
it("accepts public https bases", () => {
|
|
11
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev"), true);
|
|
12
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev/"), true);
|
|
13
|
+
assert.equal(hostallowed("https://stealthhead.example.com:8443"), true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("refuses plaintext, localhost, loopback and every private or reserved range", () => {
|
|
17
|
+
const refused = [
|
|
18
|
+
"http://gateway.wenathlan.dev",
|
|
19
|
+
"https://localhost",
|
|
20
|
+
"https://localhost:8787",
|
|
21
|
+
"https://app.localhost",
|
|
22
|
+
"https://box.local",
|
|
23
|
+
"https://service.internal",
|
|
24
|
+
"https://127.0.0.1",
|
|
25
|
+
"https://0.0.0.0",
|
|
26
|
+
"https://10.0.0.9",
|
|
27
|
+
"https://172.16.0.1",
|
|
28
|
+
"https://172.31.255.255",
|
|
29
|
+
"https://192.168.1.4",
|
|
30
|
+
"https://169.254.10.10",
|
|
31
|
+
"https://100.64.0.1",
|
|
32
|
+
"https://192.0.2.7",
|
|
33
|
+
"https://198.51.100.7",
|
|
34
|
+
"https://203.0.113.9",
|
|
35
|
+
"https://[::1]",
|
|
36
|
+
"https://[::ffff:127.0.0.1]",
|
|
37
|
+
"https://[fd00::1]",
|
|
38
|
+
"https://[fe80::1]",
|
|
39
|
+
"not a url",
|
|
40
|
+
"",
|
|
41
|
+
];
|
|
42
|
+
for (const base of refused) assert.equal(hostallowed(base), false, base);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("merges stages seen-wins — a stage never unsees", () => {
|
|
46
|
+
const fresh = { introseen: false, onboardingseen: false };
|
|
47
|
+
assert.deepEqual(mergeStage(fresh, "intro"), { introseen: true, onboardingseen: false });
|
|
48
|
+
assert.deepEqual(mergeStage(fresh, "onboarding"), { introseen: false, onboardingseen: true });
|
|
49
|
+
const half = { introseen: true, onboardingseen: false };
|
|
50
|
+
assert.deepEqual(mergeStage(half, "onboarding"), { introseen: true, onboardingseen: true });
|
|
51
|
+
assert.deepEqual(mergeStage(mergeStage(half, "onboarding"), "intro"), {
|
|
52
|
+
introseen: true,
|
|
53
|
+
onboardingseen: true,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never mutates the state it merges", () => {
|
|
58
|
+
const state = { introseen: false, onboardingseen: false };
|
|
59
|
+
mergeStage(state, "intro");
|
|
60
|
+
assert.deepEqual(state, { introseen: false, onboardingseen: false });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// # spawner.test — honest unit tests for the spawn selection and respawn
|
|
2
|
+
// pacing, runnable with the vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
|
+
// The prng is seeded, the seed rows are the real catalog, and every case
|
|
5
|
+
// checks exact replayable output — no mocks, no randomness, no dom.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
buildwave,
|
|
10
|
+
campscore,
|
|
11
|
+
DEFAULTSPAWNCONFIG,
|
|
12
|
+
distance,
|
|
13
|
+
duewaves,
|
|
14
|
+
filterbymindistance,
|
|
15
|
+
isprotected,
|
|
16
|
+
makerng,
|
|
17
|
+
nearestdistance,
|
|
18
|
+
pickspawn,
|
|
19
|
+
protectionuntil,
|
|
20
|
+
respawnwait,
|
|
21
|
+
SPAWNSEED,
|
|
22
|
+
type SpawnPick,
|
|
23
|
+
type SpawnQuery,
|
|
24
|
+
scorepoints,
|
|
25
|
+
selectspawn,
|
|
26
|
+
shortlist,
|
|
27
|
+
spawnpointsformap,
|
|
28
|
+
spawnpointsforteam,
|
|
29
|
+
withdeath,
|
|
30
|
+
} from "../spawner.ts";
|
|
31
|
+
|
|
32
|
+
/** a plain spawn query over steelhead dam for team ember. */
|
|
33
|
+
function damquery(extra: Partial<SpawnQuery> = {}): SpawnQuery {
|
|
34
|
+
return { map: "steelhead dam", team: "ember", enemies: [], occupied: [], deaths: [], tick: 0, ...extra };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("seeded prng", () => {
|
|
38
|
+
it("replays the same sequence for the same seed", () => {
|
|
39
|
+
const a = makerng(7);
|
|
40
|
+
const b = makerng(7);
|
|
41
|
+
const seqa = [a(), a(), a()];
|
|
42
|
+
const seqb = [b(), b(), b()];
|
|
43
|
+
assert.deepEqual(seqa, seqb);
|
|
44
|
+
assert.equal(makerng(7)(), seqa[0]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("stays inside [0, 1) and diverges across seeds", () => {
|
|
48
|
+
const a = makerng(1);
|
|
49
|
+
const b = makerng(2);
|
|
50
|
+
for (let i = 0; i < 16; i++) {
|
|
51
|
+
const va = a();
|
|
52
|
+
assert.ok(va >= 0 && va < 1);
|
|
53
|
+
assert.notEqual(va, b());
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("catalog filters", () => {
|
|
59
|
+
it("filters points by map and by team with neutral flow", () => {
|
|
60
|
+
assert.equal(spawnpointsformap(SPAWNSEED, "steelhead dam").length, 4);
|
|
61
|
+
assert.equal(spawnpointsforteam(SPAWNSEED, "steelhead dam", "ember").length, 2);
|
|
62
|
+
assert.equal(spawnpointsforteam(SPAWNSEED, "rift yard", "tide").length, 4);
|
|
63
|
+
assert.equal(spawnpointsformap(SPAWNSEED, "no such map").length, 0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("drops points inside the hard radius of blocked positions", () => {
|
|
67
|
+
const dam = spawnpointsforteam(SPAWNSEED, "steelhead dam", "ember");
|
|
68
|
+
const kept = filterbymindistance(dam, [dam[0].position], DEFAULTSPAWNCONFIG.mindistance);
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
kept.map((point) => point.id),
|
|
71
|
+
["sd-e2"],
|
|
72
|
+
);
|
|
73
|
+
assert.equal(filterbymindistance(dam, [], 12).length, 2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("measures distances and the nearest of a cloud", () => {
|
|
77
|
+
assert.ok(Math.abs(distance({ x: 0, y: 0, z: 0 }, { x: 3, y: 0, z: 4 }) - 5) < 1e-9);
|
|
78
|
+
assert.equal(nearestdistance({ x: 0, y: 0, z: 0 }, []), Infinity);
|
|
79
|
+
assert.equal(
|
|
80
|
+
nearestdistance({ x: 0, y: 0, z: 0 }, [
|
|
81
|
+
{ x: 3, y: 0, z: 4 },
|
|
82
|
+
{ x: 1, y: 0, z: 0 },
|
|
83
|
+
]),
|
|
84
|
+
1,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("scoring and lottery", () => {
|
|
90
|
+
it("decays camp pressure by the half-life inside the radius", () => {
|
|
91
|
+
const point = SPAWNSEED[0];
|
|
92
|
+
assert.equal(campscore(point, [], 0, DEFAULTSPAWNCONFIG), 0);
|
|
93
|
+
const fresh = campscore(point, [{ position: point.position, tick: 400 }], 400, DEFAULTSPAWNCONFIG);
|
|
94
|
+
assert.equal(fresh, 1);
|
|
95
|
+
const aged = campscore(point, [{ position: point.position, tick: 0 }], 400, DEFAULTSPAWNCONFIG);
|
|
96
|
+
assert.ok(Math.abs(aged - 0.5) < 1e-9);
|
|
97
|
+
const far = campscore(point, [{ position: { x: 500, y: 0, z: 500 }, tick: 400 }], 400, DEFAULTSPAWNCONFIG);
|
|
98
|
+
assert.equal(far, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("scores full weight when enemies are far and sorts descending", () => {
|
|
102
|
+
const picks = scorepoints(spawnpointsforteam(SPAWNSEED, "steelhead dam", "ember"), damquery(), DEFAULTSPAWNCONFIG);
|
|
103
|
+
assert.equal(picks.length, 2);
|
|
104
|
+
assert.equal(picks[0].score, 1);
|
|
105
|
+
assert.equal(picks[1].score, 0.8);
|
|
106
|
+
assert.equal(picks[0].point.id, "sd-e1");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("discounts the score when an enemy camped the point", () => {
|
|
110
|
+
const point = SPAWNSEED[0];
|
|
111
|
+
const query = damquery({ deaths: [{ position: point.position, tick: 100 }], tick: 100 });
|
|
112
|
+
const picks = scorepoints([point], query, DEFAULTSPAWNCONFIG);
|
|
113
|
+
assert.ok(picks[0].score < 0.01);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("shortlists and runs the score-proportional lottery", () => {
|
|
117
|
+
const picks: SpawnPick[] = [
|
|
118
|
+
{ point: SPAWNSEED[0], score: 1 },
|
|
119
|
+
{ point: SPAWNSEED[2], score: 3 },
|
|
120
|
+
{ point: SPAWNSEED[4], score: 0.5 },
|
|
121
|
+
];
|
|
122
|
+
assert.equal(shortlist(picks, 2).length, 2);
|
|
123
|
+
assert.equal(pickspawn(picks, () => 0.1).point.id, "sd-e1");
|
|
124
|
+
assert.equal(pickspawn(picks, () => 0.5).point.id, "sd-t1");
|
|
125
|
+
assert.equal(pickspawn(picks, () => 0.999).point.id, "ch-e1");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("the spawn pipeline", () => {
|
|
130
|
+
it("picks deterministically for the same seed", () => {
|
|
131
|
+
const first = selectspawn(SPAWNSEED, damquery(), DEFAULTSPAWNCONFIG, makerng(7));
|
|
132
|
+
const second = selectspawn(SPAWNSEED, damquery(), DEFAULTSPAWNCONFIG, makerng(7));
|
|
133
|
+
assert.ok(first);
|
|
134
|
+
assert.equal(first.point.id, second?.point.id);
|
|
135
|
+
assert.ok(SPAWNSEED.some((point) => point.id === first.point.id));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns null when safety or stacking filters wipe the map", () => {
|
|
139
|
+
const dam = spawnpointsforteam(SPAWNSEED, "steelhead dam", "ember");
|
|
140
|
+
const stacked = selectspawn(
|
|
141
|
+
SPAWNSEED,
|
|
142
|
+
damquery({ occupied: dam.map((point) => point.position) }),
|
|
143
|
+
DEFAULTSPAWNCONFIG,
|
|
144
|
+
makerng(7),
|
|
145
|
+
);
|
|
146
|
+
assert.equal(stacked, null);
|
|
147
|
+
const unsafe = selectspawn(
|
|
148
|
+
SPAWNSEED,
|
|
149
|
+
damquery({ enemies: dam.map((point) => point.position) }),
|
|
150
|
+
DEFAULTSPAWNCONFIG,
|
|
151
|
+
makerng(7),
|
|
152
|
+
);
|
|
153
|
+
assert.equal(unsafe, null);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("hard-filters enemies near a point even when others survive", () => {
|
|
157
|
+
const near = SPAWNSEED[0].position;
|
|
158
|
+
const pick = selectspawn(
|
|
159
|
+
SPAWNSEED,
|
|
160
|
+
damquery({ enemies: [{ x: near.x + 5, y: 0, z: near.z }] }),
|
|
161
|
+
DEFAULTSPAWNCONFIG,
|
|
162
|
+
makerng(7),
|
|
163
|
+
);
|
|
164
|
+
assert.ok(pick);
|
|
165
|
+
assert.notEqual(pick.point.id, "sd-e1");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("respawn pacing", () => {
|
|
170
|
+
it("prunes death marks older than the window", () => {
|
|
171
|
+
const marks = withdeath(
|
|
172
|
+
[
|
|
173
|
+
{ position: { x: 0, y: 0, z: 0 }, tick: 0 },
|
|
174
|
+
{ position: { x: 1, y: 0, z: 0 }, tick: 500 },
|
|
175
|
+
],
|
|
176
|
+
{ position: { x: 2, y: 0, z: 0 }, tick: 600 },
|
|
177
|
+
400,
|
|
178
|
+
);
|
|
179
|
+
assert.equal(marks.length, 2);
|
|
180
|
+
assert.equal(marks[marks.length - 1].tick, 600);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("escalates the respawn wait 1.5x per death and caps it", () => {
|
|
184
|
+
assert.equal(respawnwait(1, DEFAULTSPAWNCONFIG), 50);
|
|
185
|
+
assert.equal(respawnwait(3, DEFAULTSPAWNCONFIG), 113);
|
|
186
|
+
assert.equal(respawnwait(10, DEFAULTSPAWNCONFIG), 400);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("grants and expires spawn protection", () => {
|
|
190
|
+
assert.equal(protectionuntil(100, DEFAULTSPAWNCONFIG), 160);
|
|
191
|
+
assert.equal(isprotected(protectionuntil(100, DEFAULTSPAWNCONFIG), 159), true);
|
|
192
|
+
assert.equal(isprotected(protectionuntil(100, DEFAULTSPAWNCONFIG), 160), false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("wave scheduling", () => {
|
|
197
|
+
it("schedules waves on the interval grid", () => {
|
|
198
|
+
const config = { waves: 3, countperwave: 5, intervalticks: 100 };
|
|
199
|
+
assert.deepEqual(buildwave(config, 2, 50), { wave: 2, startat: 150, count: 5 });
|
|
200
|
+
assert.deepEqual(buildwave(config, 1, 50), { wave: 1, startat: 50, count: 5 });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("lists only the waves due inside the window", () => {
|
|
204
|
+
const config = { waves: 3, countperwave: 5, intervalticks: 100 };
|
|
205
|
+
assert.equal(duewaves(config, 0, 250).length, 3);
|
|
206
|
+
assert.equal(duewaves(config, 0, 150).length, 2);
|
|
207
|
+
assert.equal(duewaves(config, 0, 0).length, 1);
|
|
208
|
+
assert.deepEqual(duewaves(config, 1000, 0), []);
|
|
209
|
+
});
|
|
210
|
+
});
|