@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,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
+ });
@@ -0,0 +1,148 @@
1
+ // # weaponstats.test — honest unit tests for the armory terminal ballistics
2
+ // (falloff, shots, ttk, balance), runnable with the vitest runner:
3
+ // pnpm test
4
+ // The falloff specs and balance bounds arrive as parameters — the tests pass
5
+ // their own catalog config, exactly as a season would.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import { WEAPONCATALOG, type Weapon } from "../weapons.ts";
9
+ import {
10
+ damageatmeters,
11
+ type FalloffSpec,
12
+ shotstokill,
13
+ timetokill,
14
+ ttkbydistance,
15
+ validatebalance,
16
+ weaponstatserror,
17
+ } from "../weaponstats.ts";
18
+
19
+ /** the catalog falloff: full damage to 20 m, 60% at 60 m, linear. */
20
+ const LINEAR: FalloffSpec = { startmeters: 20, endmeters: 60, retainfraction: 0.6, curve: 1 };
21
+
22
+ /** the competitor style quadratic falloff (claude-of-duty curve). */
23
+ const QUADRATIC: FalloffSpec = { startmeters: 20, endmeters: 60, retainfraction: 0.6, curve: 2 };
24
+
25
+ /** the standard target health model of the tests. */
26
+ const HEALTH = 100;
27
+
28
+ /** finds one catalog row by id. */
29
+ function weapon(id: string): Weapon {
30
+ const row = WEAPONCATALOG.find((candidate) => candidate.id === id);
31
+ assert.ok(row, `catalog weapon ${id} missing`);
32
+ return row;
33
+ }
34
+
35
+ describe("weapon stats damage falloff", () => {
36
+ const rifle = weapon("w-1"); // 28 damage
37
+
38
+ it("keeps the full damage up to the falloff start", () => {
39
+ assert.equal(damageatmeters(rifle, 0, LINEAR), 28);
40
+ assert.equal(damageatmeters(rifle, 20, LINEAR), 28);
41
+ });
42
+
43
+ it("settles at the retained fraction past the end", () => {
44
+ assert.equal(damageatmeters(rifle, 60, LINEAR), 28 * 0.6);
45
+ assert.equal(damageatmeters(rifle, 200, LINEAR), 28 * 0.6);
46
+ });
47
+
48
+ it("fades linearly between start and end", () => {
49
+ const mid = damageatmeters(rifle, 40, LINEAR); // t 0.5
50
+ assert.ok(Math.abs(mid - 28 * (1 - 0.4 * 0.5)) < 1e-9, `expected 22.4, got ${mid}`);
51
+ });
52
+
53
+ it("bends the fade with the curve exponent", () => {
54
+ const linear = damageatmeters(rifle, 30, LINEAR);
55
+ const quad = damageatmeters(rifle, 30, QUADRATIC);
56
+ assert.ok(quad > linear, "a quadratic curve keeps more damage near the start");
57
+ assert.ok(Math.abs(linear - 28 * (1 - 0.4 * 0.25)) < 1e-9, `expected 25.2, got ${linear}`);
58
+ assert.ok(
59
+ Math.abs(quad - 28 * (1 - 0.4 * 0.25 ** 2)) < 1e-9,
60
+ `expected ${28 * (1 - 0.4 * 0.25 ** 2)}, got ${quad}`,
61
+ );
62
+ });
63
+
64
+ it("refuses a bad distance, a bad spec and a bad row", () => {
65
+ assert.throws(() => damageatmeters(rifle, -1, LINEAR), weaponstatserror);
66
+ assert.throws(
67
+ () => damageatmeters(rifle, 10, { ...LINEAR, endmeters: 10 }),
68
+ (error: unknown) => {
69
+ assert.ok(error instanceof weaponstatserror);
70
+ assert.equal(error.code, "bad-falloff");
71
+ return true;
72
+ },
73
+ );
74
+ assert.throws(() => damageatmeters({ ...rifle, damage: 0 }, 10, LINEAR), weaponstatserror);
75
+ });
76
+ });
77
+
78
+ describe("weapon stats shots and ttk", () => {
79
+ const rifle = weapon("w-1"); // 28 dmg, 660 rpm, 30 mag, 2.1 s reload
80
+
81
+ it("asks more shots as the distance fades the damage", () => {
82
+ const close = shotstokill(rifle, 10, HEALTH, LINEAR);
83
+ const mid = shotstokill(rifle, 40, HEALTH, LINEAR);
84
+ const far = shotstokill(rifle, 200, HEALTH, LINEAR);
85
+ assert.equal(close, 4); // 100 / 28
86
+ assert.ok(mid > close && far > mid, `expected the shots to grow, got ${close}/${mid}/${far}`);
87
+ });
88
+
89
+ it("counts the headshot multiplier before the division", () => {
90
+ const body = shotstokill(rifle, 10, HEALTH, LINEAR, { vitalmultiplier: 1 });
91
+ const head = shotstokill(rifle, 10, HEALTH, LINEAR, { vitalmultiplier: 2 });
92
+ assert.equal(head, Math.ceil(body / 2));
93
+ });
94
+
95
+ it("computes the ttk from the inter shot gaps and never mutates the row", () => {
96
+ const result = timetokill(rifle, 10, HEALTH, LINEAR);
97
+ assert.equal(result.shots, 4);
98
+ assert.equal(result.reloads, 0);
99
+ const gap = 60 / 660;
100
+ assert.ok(Math.abs(result.seconds - 3 * gap) < 0.0005, `expected ${3 * gap}, got ${result.seconds}`);
101
+ assert.equal(rifle.damage, 28);
102
+ });
103
+
104
+ it("charges one reload when the burst outgrows the magazine", () => {
105
+ const shotgun = weapon("w-4"); // 96 dmg, 70 rpm, 6 mag, 2.8 s reload
106
+ const result = timetokill(shotgun, 10, 600, LINEAR); // 600 hp asks 7 shots
107
+ assert.equal(result.shots, 7);
108
+ assert.equal(result.reloads, 1);
109
+ const gap = 60 / 70;
110
+ assert.ok(Math.abs(result.seconds - (6 * gap + 2.8)) < 0.0005, `expected ${6 * gap + 2.8}, got ${result.seconds}`);
111
+ });
112
+
113
+ it("sweeps a distance ladder in order for the armory curve", () => {
114
+ const curve = ttkbydistance(rifle, [10, 40, 120], HEALTH, LINEAR);
115
+ assert.equal(curve.length, 3);
116
+ assert.ok(curve[0].seconds < curve[1].seconds && curve[1].seconds < curve[2].seconds);
117
+ });
118
+
119
+ it("refuses a bad health pool", () => {
120
+ assert.throws(
121
+ () => shotstokill(rifle, 10, 0, LINEAR),
122
+ (error: unknown) => {
123
+ assert.ok(error instanceof weaponstatserror);
124
+ assert.equal(error.code, "bad-health");
125
+ return true;
126
+ },
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("weapon stats balance validation", () => {
132
+ it("accepts every catalog row inside the season bounds", () => {
133
+ const bounds = { damage: { min: 10, max: 100 }, firerate: { min: 60, max: 1000 }, recoil: { min: 10, max: 90 } };
134
+ const violations = WEAPONCATALOG.flatMap((row) => validatebalance(row, bounds));
135
+ assert.equal(violations.length, 0);
136
+ });
137
+
138
+ it("reports each field that leaves the configured bounds", () => {
139
+ const bounds = { damage: { min: 20, max: 80 }, reloadseconds: { min: 1, max: 3 } };
140
+ const violations = validatebalance(weapon("w-2"), bounds); // 88 dmg, 3.2 s reload
141
+ assert.equal(violations.length, 2);
142
+ assert.deepEqual(violations.map((violation) => violation.field).sort(), ["damage", "reloadseconds"]);
143
+ });
144
+
145
+ it("skips the fields without bounds and passes an empty bounds table", () => {
146
+ assert.deepEqual(validatebalance(weapon("w-4"), {}), []);
147
+ });
148
+ });