@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.
- package/README.md +16 -2
- package/docs/.gitkeep +0 -0
- package/package.json +141 -4
- package/tests/.gitkeep +0 -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/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
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# stealthhead
|
|
2
|
+
|
|
3
|
+
FPS game platform. Imports versawase from cadria and audio from debonair via the published library, with no engine file of its own; only game logics (match, ranking, weapons, world). This folder receives the stealthhead application per the complete tree.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The Sol theme
|
|
8
|
+
|
|
9
|
+
# Stealthhead Sol
|
|
10
|
+
|
|
11
|
+
Stealthhead is the FPS world and game application of the DevThink family: matches, rounds, ranking ladders, weapons and world assets under one game shell. This Sol theme is its web surface — a Vite + React SPA whose home, match, ranking, weapons and world routes render from the same sqlite database that `schema.prisma` and `drizzle.config.ts` describe.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install
|
|
15
|
+
pnpm dev
|
|
16
|
+
```
|
|
2
17
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
package/docs/.gitkeep
ADDED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,143 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/stealthhead",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "2.0.94",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/wenathlan/devthink"
|
|
7
|
+
},
|
|
8
|
+
"description": "stealthhead is the FPS game platform of the wenathlan family: it imports versawase from cadria and audio from debonair via the published library, with no engine file of its own — only game logics (match, ranking, weapons, world). The envelope distributes the documentation set and the tests directory as one package.",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"stealthhead",
|
|
11
|
+
"fps",
|
|
12
|
+
"game",
|
|
13
|
+
"match",
|
|
14
|
+
"ranking",
|
|
15
|
+
"gameplay",
|
|
16
|
+
"documentation"
|
|
17
|
+
],
|
|
18
|
+
"license": "GPL-3.0-only",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "vite --host",
|
|
22
|
+
"build": "vite build",
|
|
23
|
+
"start": "vite preview --host",
|
|
24
|
+
"preview": "vite preview --host",
|
|
25
|
+
"check": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@hono/node-server": "^2.1.3",
|
|
30
|
+
"@prisma/adapter-libsql": "^7.10.0",
|
|
31
|
+
"hono": "^4.13.12",
|
|
32
|
+
"ink": "^7.1.1",
|
|
33
|
+
"react-devtools-core": "^8.0.0",
|
|
34
|
+
"z-ai-web-dev-sdk": "^0.0.18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@biomejs/biome": "^2.5.15",
|
|
38
|
+
"@capacitor/android": "^8.5.2",
|
|
39
|
+
"@capacitor/cli": "^8.5.2",
|
|
40
|
+
"@capacitor/core": "^8.5.2",
|
|
41
|
+
"@capacitor/ios": "^8.5.2",
|
|
42
|
+
"@hookform/resolvers": "^5.9.1",
|
|
43
|
+
"@libsql/client": "^0.18.0",
|
|
44
|
+
"@opencode-ai/sdk": "^1.18.34",
|
|
45
|
+
"@prisma/client": "^7.10.0",
|
|
46
|
+
"@radix-ui/react-accordion": "^1.2.20",
|
|
47
|
+
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
48
|
+
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
|
49
|
+
"@radix-ui/react-avatar": "^1.2.6",
|
|
50
|
+
"@radix-ui/react-checkbox": "^1.3.11",
|
|
51
|
+
"@radix-ui/react-collapsible": "^1.1.20",
|
|
52
|
+
"@radix-ui/react-context-menu": "^2.3.7",
|
|
53
|
+
"@radix-ui/react-dialog": "^1.1.23",
|
|
54
|
+
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
|
55
|
+
"@radix-ui/react-hover-card": "^1.1.23",
|
|
56
|
+
"@radix-ui/react-label": "^2.1.15",
|
|
57
|
+
"@radix-ui/react-menubar": "^1.1.24",
|
|
58
|
+
"@radix-ui/react-navigation-menu": "^1.2.22",
|
|
59
|
+
"@radix-ui/react-popover": "^1.1.23",
|
|
60
|
+
"@radix-ui/react-progress": "^1.1.16",
|
|
61
|
+
"@radix-ui/react-radio-group": "^1.4.7",
|
|
62
|
+
"@radix-ui/react-scroll-area": "^1.2.18",
|
|
63
|
+
"@radix-ui/react-select": "^2.3.7",
|
|
64
|
+
"@radix-ui/react-separator": "^1.1.15",
|
|
65
|
+
"@radix-ui/react-slider": "^1.4.7",
|
|
66
|
+
"@radix-ui/react-slot": "^1.3.3",
|
|
67
|
+
"@radix-ui/react-switch": "^1.3.7",
|
|
68
|
+
"@radix-ui/react-tabs": "^1.1.21",
|
|
69
|
+
"@radix-ui/react-toggle": "^1.1.18",
|
|
70
|
+
"@radix-ui/react-toggle-group": "^1.1.19",
|
|
71
|
+
"@radix-ui/react-tooltip": "^1.2.16",
|
|
72
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
73
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
74
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
75
|
+
"@tsconfig/node26": "^26.0.1",
|
|
76
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
77
|
+
"@types/chrome": "^0.3.4",
|
|
78
|
+
"@types/express": "^5.0.6",
|
|
79
|
+
"@types/node": "^26.6.3",
|
|
80
|
+
"@types/react": "^19.3.0",
|
|
81
|
+
"@types/react-dom": "^19.3.0",
|
|
82
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
83
|
+
"add": "^2.0.9",
|
|
84
|
+
"autoprefixer": "^10.6.1",
|
|
85
|
+
"axios": "^1.20.0",
|
|
86
|
+
"better-sqlite3": "^13.0.3",
|
|
87
|
+
"bun-types": "^1.4.2",
|
|
88
|
+
"class-variance-authority": "^0.7.1",
|
|
89
|
+
"clsx": "^2.1.1",
|
|
90
|
+
"cmdk": "^1.1.1",
|
|
91
|
+
"drizzle-kit": "^0.31.11",
|
|
92
|
+
"drizzle-orm": "^0.45.3",
|
|
93
|
+
"embla-carousel-react": "^8.6.0",
|
|
94
|
+
"esbuild": "^0.28.2",
|
|
95
|
+
"express": "^5.2.1",
|
|
96
|
+
"fast-check": "^4.10.2",
|
|
97
|
+
"framer-motion": "^13.5.0",
|
|
98
|
+
"input-otp": "^1.5.0",
|
|
99
|
+
"lucide-react": "^1.49.0",
|
|
100
|
+
"mysql2": "^3.23.1",
|
|
101
|
+
"nanoid": "^6.0.1",
|
|
102
|
+
"next-themes": "^0.4.6",
|
|
103
|
+
"pnpm": "^12.8.1",
|
|
104
|
+
"postcss": "^8.5.28",
|
|
105
|
+
"prettier": "^3.9.9",
|
|
106
|
+
"prisma": "^7.10.0",
|
|
107
|
+
"react": "^19.3.0",
|
|
108
|
+
"react-day-picker": "^10.0.2",
|
|
109
|
+
"react-dom": "^19.3.0",
|
|
110
|
+
"react-hook-form": "^7.89.0",
|
|
111
|
+
"react-resizable-panels": "^4.14.1",
|
|
112
|
+
"recharts": "^3.10.1",
|
|
113
|
+
"sonner": "^2.0.8",
|
|
114
|
+
"streamdown": "^2.7.0",
|
|
115
|
+
"tailwind-merge": "^3.7.0",
|
|
116
|
+
"tailwindcss": "^4.3.3",
|
|
117
|
+
"tailwindcss-animate": "^1.0.7",
|
|
118
|
+
"tsup": "^8.5.1",
|
|
119
|
+
"tsx": "^4.23.15",
|
|
120
|
+
"tw-animate-css": "^1.4.0",
|
|
121
|
+
"typescript": "^7.0.2",
|
|
122
|
+
"vaul": "^1.1.2",
|
|
123
|
+
"vite": "^8.3.2",
|
|
124
|
+
"vitest": "^5.0.3",
|
|
125
|
+
"wouter": "^3.13.0",
|
|
126
|
+
"yaml": "^2.9.1",
|
|
127
|
+
"zod": "^4.6.5"
|
|
128
|
+
},
|
|
129
|
+
"files": [
|
|
130
|
+
"README.md",
|
|
131
|
+
"docs",
|
|
132
|
+
"tests"
|
|
133
|
+
],
|
|
134
|
+
"homepage": "https://github.com/wenathlan/devthink/tree/main/stealthhead",
|
|
135
|
+
"bugs": {
|
|
136
|
+
"url": "https://github.com/wenathlan/devthink/issues"
|
|
137
|
+
},
|
|
138
|
+
"publishConfig": {
|
|
139
|
+
"access": "public",
|
|
140
|
+
"registry": "https://registry.npmjs.org/"
|
|
141
|
+
},
|
|
142
|
+
"packageManager": "pnpm@12.3.4"
|
|
143
|
+
}
|
package/tests/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* familyurl.test.ts — the sibling link builder of the rail foot: one slug
|
|
3
|
+
* in, the relative family URL out (edge slashes trimmed, trailing slash
|
|
4
|
+
* included), exactly the shape the window chrome links the family with.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { familyurl } from "../familyurl";
|
|
8
|
+
|
|
9
|
+
describe("familyurl", () => {
|
|
10
|
+
it("builds the relative sibling url with the trailing slash", () => {
|
|
11
|
+
expect(familyurl("cadria")).toBe("../cadria/");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("trims the surrounding slashes of the slug", () => {
|
|
15
|
+
expect(familyurl("/cadria/")).toBe("../cadria/");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("trims repeated edge slashes on both sides", () => {
|
|
19
|
+
expect(familyurl("///debonair///")).toBe("../debonair/");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps the interior of the slug untouched", () => {
|
|
23
|
+
expect(familyurl("devthink")).toBe("../devthink/");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -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
|
+
});
|