@cs2dak/core 1.0.0 → 2.0.0
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/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +46 -53
- package/src/index.ts +25 -8
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +27 -1
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/signals.ts +149 -112
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +4 -4
- package/src/economy.test.ts +0 -37
- package/src/economy.ts +0 -57
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 空间分析的共享类型(严格重建 SP1)。
|
|
3
|
+
* 设计约束见 docs/design/rr-model.md §3。
|
|
4
|
+
*
|
|
5
|
+
* 两层结构:raw evidence(宽,复盘/shadow) → official gated features(窄,进 RR)。
|
|
6
|
+
* 本文件定义 phase 模型与 raw evidence 的形状;official 派生在后续 sub-project。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 回合阶段。official scoring 排除 freeze / save / exit(见 OFFICIAL_EXCLUDED_PHASES)。 */
|
|
10
|
+
export type RoundPhase =
|
|
11
|
+
| "freeze"
|
|
12
|
+
| "default"
|
|
13
|
+
| "take"
|
|
14
|
+
| "execute"
|
|
15
|
+
| "postPlant"
|
|
16
|
+
| "retake"
|
|
17
|
+
| "save"
|
|
18
|
+
| "exit"
|
|
19
|
+
| "clutch";
|
|
20
|
+
|
|
21
|
+
/** save / exit / freeze 默认不进 official MapControl/UtilitySpatial(仍进 review 层)。 */
|
|
22
|
+
export const OFFICIAL_EXCLUDED_PHASES: ReadonlySet<RoundPhase> = new Set<RoundPhase>([
|
|
23
|
+
"freeze",
|
|
24
|
+
"save",
|
|
25
|
+
"exit",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function isOfficialScoringPhase(phase: RoundPhase): boolean {
|
|
29
|
+
return !OFFICIAL_EXCLUDED_PHASES.has(phase);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 单回合的阶段关键 tick。phase 用 `phaseAtTick` 从这些边界派生,保持可序列化、无函数。
|
|
34
|
+
*
|
|
35
|
+
* MVP 覆盖:freeze / default / take / execute / postPlant / clutch(均可从
|
|
36
|
+
* rounds + bombs + kills(+ 可选 positions/routes)确定性派生)。
|
|
37
|
+
* retake / save / exit 留待后续细化——当前 postPlant 窗口对双方通用,
|
|
38
|
+
* side-aware gate 自行区分 T 守包 vs CT retake。
|
|
39
|
+
*/
|
|
40
|
+
export interface RoundPhaseModel {
|
|
41
|
+
roundNumber: number;
|
|
42
|
+
startTick: number;
|
|
43
|
+
freezeEndTick: number;
|
|
44
|
+
endTick: number;
|
|
45
|
+
/** 炸弹安放 tick;未安放为 null。 */
|
|
46
|
+
plantTick: number | null;
|
|
47
|
+
/** T 首次推进到出生区外(routeIndex ≥ 1)的 tick;无 positions/routes 时 null。 */
|
|
48
|
+
takeTick: number | null;
|
|
49
|
+
/** 多名 T 逼近包点入口或多颗进攻道具生效的 tick;无证据时 null。 */
|
|
50
|
+
executeTick: number | null;
|
|
51
|
+
/** 一方仅剩 1 人存活(另一方 ≥ 1)的首个 tick;未进入残局为 null。 */
|
|
52
|
+
clutchStartTick: number | null;
|
|
53
|
+
/** 阶段证据质量:positions/routes 是否可用(影响 take/execute 是否可信)。 */
|
|
54
|
+
hasPositions: boolean;
|
|
55
|
+
hasRoutes: boolean;
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { CompactNav, MapZone } from "@cs2dak/maps";
|
|
3
|
+
import {
|
|
4
|
+
areasWithinRadius,
|
|
5
|
+
buildNavIndex,
|
|
6
|
+
navPathCost,
|
|
7
|
+
nearestAreaId,
|
|
8
|
+
polygonCentroid,
|
|
9
|
+
segmentSphereIntersects,
|
|
10
|
+
smokeDetourCost,
|
|
11
|
+
} from "./utility-geometry.js";
|
|
12
|
+
|
|
13
|
+
const v = (x: number, y: number, z = 0) => ({ x, y, z });
|
|
14
|
+
|
|
15
|
+
describe("segmentSphereIntersects", () => {
|
|
16
|
+
it("hits a sphere on the segment path", () => {
|
|
17
|
+
expect(segmentSphereIntersects(v(0, 0), v(100, 0), v(50, 10), 20)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("misses a sphere far from the segment", () => {
|
|
20
|
+
expect(segmentSphereIntersects(v(0, 0), v(100, 0), v(50, 200), 20)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it("hits when an endpoint is inside the sphere", () => {
|
|
23
|
+
expect(segmentSphereIntersects(v(0, 0), v(100, 0), v(105, 0), 10)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it("misses past the segment end (clamped)", () => {
|
|
26
|
+
expect(segmentSphereIntersects(v(0, 0), v(100, 0), v(200, 0), 10)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("polygonCentroid", () => {
|
|
31
|
+
it("returns the center of a square", () => {
|
|
32
|
+
const zone = { id: "z", name: "z", role: "site", polygon: [[0, 0], [100, 0], [100, 100], [0, 100]] } as unknown as MapZone;
|
|
33
|
+
const c = polygonCentroid(zone);
|
|
34
|
+
expect(c.x).toBeCloseTo(50, 5);
|
|
35
|
+
expect(c.y).toBeCloseTo(50, 5);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** 线性 nav:A(0)-B(100)-C(200)-D(300),外加旁路 B-E(100,200)-C 绕远。 */
|
|
40
|
+
function lineNav(): CompactNav {
|
|
41
|
+
return {
|
|
42
|
+
mapName: "de_test", buildId: 0, sourceFormat: { version: 0, subVersion: 0 },
|
|
43
|
+
areas: [
|
|
44
|
+
{ id: 0, corners: [], centroid: v(0, 0), neighbors: [1] },
|
|
45
|
+
{ id: 1, corners: [], centroid: v(100, 0), neighbors: [0, 2, 4] },
|
|
46
|
+
{ id: 2, corners: [], centroid: v(200, 0), neighbors: [1, 3, 4] },
|
|
47
|
+
{ id: 3, corners: [], centroid: v(300, 0), neighbors: [2] },
|
|
48
|
+
{ id: 4, corners: [], centroid: v(150, 300), neighbors: [1, 2] }, // 旁路(绕远)
|
|
49
|
+
],
|
|
50
|
+
} as unknown as CompactNav;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("navPathCost + smokeDetourCost", () => {
|
|
54
|
+
it("computes straight-line path cost", () => {
|
|
55
|
+
const idx = buildNavIndex(lineNav());
|
|
56
|
+
expect(navPathCost(idx, 0, 3)).toBeCloseTo(300, 3); // 0→1→2→3
|
|
57
|
+
});
|
|
58
|
+
it("returns null when blocked with no alternative", () => {
|
|
59
|
+
const idx = buildNavIndex(lineNav());
|
|
60
|
+
// 屏蔽 1 → 0 无法到达任何地方(0 只连 1)
|
|
61
|
+
expect(navPathCost(idx, 0, 3, new Set([1]))).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
it("smoke detour: blocking the direct hop forces the long way around", () => {
|
|
64
|
+
const idx = buildNavIndex(lineNav());
|
|
65
|
+
// 屏蔽 2(直线中段)→ 1→4→2 不行(2 也被屏蔽),但 1→4→? ... 改测屏蔽直连边场景:
|
|
66
|
+
// 屏蔽节点 2 后从 1 到 3:1→4→2(blocked) 无效 → 实际不可达 → detour=base(全额)
|
|
67
|
+
const detour = smokeDetourCost(idx, 1, 3, new Set([2]));
|
|
68
|
+
expect(detour).toBeGreaterThan(0);
|
|
69
|
+
});
|
|
70
|
+
it("smoke detour is 0 when smoke does not lie on the path", () => {
|
|
71
|
+
const idx = buildNavIndex(lineNav());
|
|
72
|
+
expect(smokeDetourCost(idx, 0, 3, new Set([4]))).toBe(0); // 旁路被屏蔽不影响直线
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("nearestAreaId + areasWithinRadius", () => {
|
|
77
|
+
it("finds the nearest area", () => {
|
|
78
|
+
const idx = buildNavIndex(lineNav());
|
|
79
|
+
expect(nearestAreaId(idx, v(190, 5))).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
it("collects areas within a radius", () => {
|
|
82
|
+
const idx = buildNavIndex(lineNav());
|
|
83
|
+
const within = areasWithinRadius(idx, v(100, 0), 110);
|
|
84
|
+
expect(within.has(1)).toBe(true); // 距 0
|
|
85
|
+
expect(within.has(0)).toBe(true); // 距 100
|
|
86
|
+
expect(within.has(3)).toBe(false); // 距 200 > 110
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UtilitySpatial 几何地基(SP3 v2)。纯几何 + nav 拓扑,可独立单测,不依赖 demo。
|
|
3
|
+
* 供烟雾视线封锁(segment-sphere)与烟雾隔离(nav 绕路代价)使用。
|
|
4
|
+
*/
|
|
5
|
+
import type { CompactNav, MapZone, Vec3 } from "@cs2dak/maps";
|
|
6
|
+
|
|
7
|
+
/** CS2 道具有效半径(世界单位,近似)。烟雾球 ~144、火焰面 ~150。 */
|
|
8
|
+
export const SMOKE_RADIUS = 144;
|
|
9
|
+
export const FIRE_RADIUS = 150;
|
|
10
|
+
|
|
11
|
+
/** 线段 AB 是否穿过以 center 为心、radius 为半径的球(即最近点距离 < radius)。 */
|
|
12
|
+
export function segmentSphereIntersects(a: Vec3, b: Vec3, center: Vec3, radius: number): boolean {
|
|
13
|
+
const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z;
|
|
14
|
+
const apx = center.x - a.x, apy = center.y - a.y, apz = center.z - a.z;
|
|
15
|
+
const abLen2 = abx * abx + aby * aby + abz * abz;
|
|
16
|
+
let t = abLen2 > 0 ? (apx * abx + apy * aby + apz * abz) / abLen2 : 0;
|
|
17
|
+
t = Math.max(0, Math.min(1, t));
|
|
18
|
+
const cx = a.x + abx * t, cy = a.y + aby * t, cz = a.z + abz * t;
|
|
19
|
+
const dx = center.x - cx, dy = center.y - cy, dz = center.z - cz;
|
|
20
|
+
return dx * dx + dy * dy + dz * dz <= radius * radius;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 多边形质心(XY,世界坐标)。退化时回退到顶点平均。 */
|
|
24
|
+
export function polygonCentroid(zone: MapZone): { x: number; y: number } {
|
|
25
|
+
const p = zone.polygon;
|
|
26
|
+
let area = 0, cx = 0, cy = 0;
|
|
27
|
+
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
|
|
28
|
+
const [xi, yi] = p[i]!;
|
|
29
|
+
const [xj, yj] = p[j]!;
|
|
30
|
+
const cross = xj * yi - xi * yj;
|
|
31
|
+
area += cross;
|
|
32
|
+
cx += (xi + xj) * cross;
|
|
33
|
+
cy += (yi + yj) * cross;
|
|
34
|
+
}
|
|
35
|
+
if (Math.abs(area) < 1e-6) {
|
|
36
|
+
const n = p.length || 1;
|
|
37
|
+
return { x: p.reduce((s, q) => s + q[0], 0) / n, y: p.reduce((s, q) => s + q[1], 0) / n };
|
|
38
|
+
}
|
|
39
|
+
area *= 0.5;
|
|
40
|
+
return { x: cx / (6 * area), y: cy / (6 * area) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** nav 索引:id → area,邻接 + 质心,供 Dijkstra 复用(避免每次线性扫)。 */
|
|
44
|
+
export interface NavIndex {
|
|
45
|
+
byId: Map<number, { centroid: Vec3; neighbors: number[] }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildNavIndex(nav: CompactNav): NavIndex {
|
|
49
|
+
const byId = new Map<number, { centroid: Vec3; neighbors: number[] }>();
|
|
50
|
+
for (const area of nav.areas) {
|
|
51
|
+
byId.set(area.id, { centroid: area.centroid, neighbors: area.neighbors });
|
|
52
|
+
}
|
|
53
|
+
return byId.size ? { byId } : { byId };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 最近 nav area id(按质心欧氏距离)。 */
|
|
57
|
+
export function nearestAreaId(index: NavIndex, point: Vec3): number | null {
|
|
58
|
+
let best: number | null = null;
|
|
59
|
+
let bestD = Infinity;
|
|
60
|
+
for (const [id, a] of index.byId) {
|
|
61
|
+
const dx = a.centroid.x - point.x, dy = a.centroid.y - point.y, dz = a.centroid.z - point.z;
|
|
62
|
+
const d = dx * dx + dy * dy + dz * dz;
|
|
63
|
+
if (d < bestD) { bestD = d; best = id; }
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 质心落在 center 半径内的 nav area 集合(烟/火覆盖的可达区域)。 */
|
|
69
|
+
export function areasWithinRadius(index: NavIndex, center: Vec3, radius: number): Set<number> {
|
|
70
|
+
const out = new Set<number>();
|
|
71
|
+
const r2 = radius * radius;
|
|
72
|
+
for (const [id, a] of index.byId) {
|
|
73
|
+
const dx = a.centroid.x - center.x, dy = a.centroid.y - center.y, dz = a.centroid.z - center.z;
|
|
74
|
+
if (dx * dx + dy * dy + dz * dz <= r2) out.add(id);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* nav 最短路径代价(沿邻接、质心欧氏距离加权的 Dijkstra),可屏蔽 blocked 区域。
|
|
81
|
+
* 不可达 / 起终点被屏蔽 → 返回 null。
|
|
82
|
+
*/
|
|
83
|
+
export function navPathCost(
|
|
84
|
+
index: NavIndex,
|
|
85
|
+
startId: number,
|
|
86
|
+
endId: number,
|
|
87
|
+
blocked?: ReadonlySet<number>,
|
|
88
|
+
): number | null {
|
|
89
|
+
if (blocked?.has(startId) || blocked?.has(endId)) return null;
|
|
90
|
+
if (startId === endId) return 0;
|
|
91
|
+
const dist = new Map<number, number>([[startId, 0]]);
|
|
92
|
+
const heap = new MinHeap();
|
|
93
|
+
heap.push(startId, 0);
|
|
94
|
+
while (heap.size > 0) {
|
|
95
|
+
const { id, cost } = heap.pop()!;
|
|
96
|
+
if (id === endId) return cost;
|
|
97
|
+
if (cost > (dist.get(id) ?? Infinity)) continue;
|
|
98
|
+
const area = index.byId.get(id);
|
|
99
|
+
if (!area) continue;
|
|
100
|
+
for (const nb of area.neighbors) {
|
|
101
|
+
if (blocked?.has(nb)) continue;
|
|
102
|
+
const nbArea = index.byId.get(nb);
|
|
103
|
+
if (!nbArea) continue;
|
|
104
|
+
const dx = nbArea.centroid.x - area.centroid.x;
|
|
105
|
+
const dy = nbArea.centroid.y - area.centroid.y;
|
|
106
|
+
const dz = nbArea.centroid.z - area.centroid.z;
|
|
107
|
+
const w = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
108
|
+
const nd = cost + w;
|
|
109
|
+
if (nd < (dist.get(nb) ?? Infinity)) {
|
|
110
|
+
dist.set(nb, nd);
|
|
111
|
+
heap.push(nb, nd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 烟雾绕路代价:屏蔽烟覆盖的 nav 区域后,enemy→objective 多走多少(≤0 表示无影响)。 */
|
|
119
|
+
export function smokeDetourCost(
|
|
120
|
+
index: NavIndex,
|
|
121
|
+
enemyAreaId: number,
|
|
122
|
+
objectiveAreaId: number,
|
|
123
|
+
smokeBlocked: ReadonlySet<number>,
|
|
124
|
+
): number {
|
|
125
|
+
const base = navPathCost(index, enemyAreaId, objectiveAreaId);
|
|
126
|
+
if (base == null) return 0;
|
|
127
|
+
const blockedCost = navPathCost(index, enemyAreaId, objectiveAreaId, smokeBlocked);
|
|
128
|
+
if (blockedCost == null) return base; // 烟把唯一路径彻底切断 → 全额计
|
|
129
|
+
return Math.max(0, blockedCost - base);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 极简二叉堆(id + cost)。 */
|
|
133
|
+
class MinHeap {
|
|
134
|
+
private heap: Array<{ id: number; cost: number }> = [];
|
|
135
|
+
get size(): number { return this.heap.length; }
|
|
136
|
+
push(id: number, cost: number): void {
|
|
137
|
+
const h = this.heap;
|
|
138
|
+
h.push({ id, cost });
|
|
139
|
+
let i = h.length - 1;
|
|
140
|
+
while (i > 0) {
|
|
141
|
+
const p = (i - 1) >> 1;
|
|
142
|
+
if (h[p]!.cost <= h[i]!.cost) break;
|
|
143
|
+
[h[p], h[i]] = [h[i]!, h[p]!];
|
|
144
|
+
i = p;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
pop(): { id: number; cost: number } | undefined {
|
|
148
|
+
const h = this.heap;
|
|
149
|
+
if (h.length === 0) return undefined;
|
|
150
|
+
const top = h[0];
|
|
151
|
+
const last = h.pop()!;
|
|
152
|
+
if (h.length > 0) {
|
|
153
|
+
h[0] = last;
|
|
154
|
+
let i = 0;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const l = 2 * i + 1, r = 2 * i + 2;
|
|
157
|
+
let s = i;
|
|
158
|
+
if (l < h.length && h[l]!.cost < h[s]!.cost) s = l;
|
|
159
|
+
if (r < h.length && h[r]!.cost < h[s]!.cost) s = r;
|
|
160
|
+
if (s === i) break;
|
|
161
|
+
[h[s], h[i]] = [h[i]!, h[s]!];
|
|
162
|
+
i = s;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return top;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
5
|
+
import { getMapTri } from "@cs2dak/maps/tri-assets";
|
|
6
|
+
import { loadDemoPackageFromZip } from "../loader.js";
|
|
7
|
+
import { loadSpatialAssets } from "./annotate.js";
|
|
8
|
+
import { buildOfficialUtilitySpatial } from "./utility.js";
|
|
9
|
+
|
|
10
|
+
let deAncientPkg: DemoPackage | null = null;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const zip = await readFile(fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)));
|
|
14
|
+
deAncientPkg = await loadDemoPackageFromZip(zip);
|
|
15
|
+
}, 30_000);
|
|
16
|
+
|
|
17
|
+
describe("UtilitySpatial LOS metrics on real fixture (tri-backed)", () => {
|
|
18
|
+
it("derives non-null sightline denial / protected crossings when tri-BVH is available", () => {
|
|
19
|
+
const pkg = deAncientPkg!;
|
|
20
|
+
const mapName = pkg.match.mapName;
|
|
21
|
+
const tri = getMapTri(mapName);
|
|
22
|
+
if (!tri) {
|
|
23
|
+
// 本机未下载 ~/.awpy/tris/{mapName}.tri -> 跳过(CI 无 tri)
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const assets = loadSpatialAssets(mapName, tri);
|
|
27
|
+
expect(assets.available.visibility).toBe(true);
|
|
28
|
+
|
|
29
|
+
const u = buildOfficialUtilitySpatial(pkg, assets);
|
|
30
|
+
let sightTotal = 0;
|
|
31
|
+
for (const v of u.values()) {
|
|
32
|
+
expect(v.actualSmokeSightlineDenialSeconds).not.toBeNull();
|
|
33
|
+
expect(v.actualSmokeProtectedCrossings).not.toBeNull();
|
|
34
|
+
sightTotal += v.actualSmokeSightlineDenialSeconds ?? 0;
|
|
35
|
+
}
|
|
36
|
+
expect(sightTotal).toBeGreaterThanOrEqual(0);
|
|
37
|
+
}, 60_000);
|
|
38
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { DemoPackage, Replay } from "@cs2dak/contract";
|
|
5
|
+
import type { MapZones } from "@cs2dak/maps";
|
|
6
|
+
import { loadDemoPackageFromZip } from "../loader.js";
|
|
7
|
+
import { loadSpatialAssets, type SpatialAssets } from "./annotate.js";
|
|
8
|
+
import { buildOfficialUtilitySpatial, buildUtilityWindows } from "./utility.js";
|
|
9
|
+
|
|
10
|
+
let deAncientPkg: DemoPackage | null = null;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const zip = await readFile(fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)));
|
|
14
|
+
deAncientPkg = await loadDemoPackageFromZip(zip);
|
|
15
|
+
}, 30_000);
|
|
16
|
+
|
|
17
|
+
function deltaArr(values: number[]): number[] {
|
|
18
|
+
const out: number[] = [];
|
|
19
|
+
let prev = 0;
|
|
20
|
+
for (const v of values) { out.push(v - prev); prev = v; }
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 合成 1 个方形 A 点 zone(无 nav/tri)——测归属 + 火焰逼退(zone-based,不需要 nav)。
|
|
25
|
+
const SITE_ZONES: MapZones = {
|
|
26
|
+
mapName: "de_test",
|
|
27
|
+
version: "t",
|
|
28
|
+
zones: [{ id: "a_site", name: "A", role: "site", bombsite: "a", polygon: [[0, 0], [100, 0], [100, 100], [0, 100]] }],
|
|
29
|
+
};
|
|
30
|
+
const TEST_ASSETS: SpatialAssets = {
|
|
31
|
+
mapName: "de_test", routes: null, zones: SITE_ZONES, nav: null, visibility: null,
|
|
32
|
+
available: { routes: false, zones: true, nav: false, visibility: false },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Build replay round for C1 at two positions: (50,50) at tick 96, (500,500) at tick 160. */
|
|
36
|
+
function molotovReplay(): Replay {
|
|
37
|
+
return {
|
|
38
|
+
meta: { sampleRate: 1, tickrate: 64, coordScale: 1, angleScale: 10 },
|
|
39
|
+
weaponDict: [],
|
|
40
|
+
placeDict: [],
|
|
41
|
+
rounds: [{
|
|
42
|
+
roundNumber: 1,
|
|
43
|
+
startTick: 96,
|
|
44
|
+
tickStep: 64,
|
|
45
|
+
frameCount: 2,
|
|
46
|
+
players: [{
|
|
47
|
+
playerIndex: 1, // C1
|
|
48
|
+
x: deltaArr([50, 500]), y: deltaArr([50, 500]), z: deltaArr([0, 0]),
|
|
49
|
+
yaw: deltaArr([0, 0]), pitch: deltaArr([0, 0]),
|
|
50
|
+
hp: [100, 100], armor: [100, 100], money: [800, 800], equipValue: [800, 800],
|
|
51
|
+
weapon: [-1, -1], place: [-1, -1], flash: [0, 0], flags: [1, 1], grenades: [[], []],
|
|
52
|
+
}],
|
|
53
|
+
projectiles: [],
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeMolotovPkg(): DemoPackage {
|
|
59
|
+
return {
|
|
60
|
+
match: { mapName: "de_test", tickrate: 64 },
|
|
61
|
+
players: [{ steamId64: "T1", teamKey: "teamA" }, { steamId64: "C1", teamKey: "teamB" }],
|
|
62
|
+
rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 64, endTick: 1000, teamASide: "t", teamBSide: "ct" }],
|
|
63
|
+
bombs: [], kills: [],
|
|
64
|
+
grenades: [{ roundNumber: 1, grenadeId: "g1", grenade: "molotov", throwerSteamId64: "T1", throwerTeamKey: "teamA", throwTick: 80, effectTick: 100, destroyTick: 196, effectPosition: { x: 50, y: 50, z: 0 } }],
|
|
65
|
+
replay: molotovReplay(),
|
|
66
|
+
} as unknown as DemoPackage;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("buildUtilityWindows (zone attribution, doc §18)", () => {
|
|
70
|
+
it("attributes a grenade to its effectPosition zone", () => {
|
|
71
|
+
const windows = buildUtilityWindows(makeMolotovPkg(), TEST_ASSETS);
|
|
72
|
+
expect(windows[0]!.zoneId).toBe("a_site");
|
|
73
|
+
expect(windows[0]!.zoneRole).toBe("site");
|
|
74
|
+
expect(windows[0]!.type).toBe("molotov");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("incendiary displacement / path delay (zone-based)", () => {
|
|
79
|
+
it("counts displacement when an enemy leaves the fire zone, and path delay", () => {
|
|
80
|
+
const u = buildOfficialUtilitySpatial(makeMolotovPkg(), TEST_ASSETS);
|
|
81
|
+
const t1 = u.get("T1")!;
|
|
82
|
+
expect(t1.actualIncendiaryDisplacementEvents).toBe(1); // site weight 1.0
|
|
83
|
+
expect(t1.actualIncendiaryPathDelaySeconds).toBeGreaterThan(0);
|
|
84
|
+
// 无 visibility → LOS 两项发 null
|
|
85
|
+
expect(t1.actualSmokeProtectedCrossings).toBeNull();
|
|
86
|
+
expect(t1.actualSmokeSightlineDenialSeconds).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns an empty map without zone assets", () => {
|
|
90
|
+
const noZones: SpatialAssets = { ...TEST_ASSETS, zones: null, available: { ...TEST_ASSETS.available, zones: false } };
|
|
91
|
+
expect(buildOfficialUtilitySpatial(makeMolotovPkg(), noZones).size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("UtilitySpatial end-to-end on real fixture (nav-detour isolation, no tri)", () => {
|
|
96
|
+
it("attributes real grenades and derives nav-backed isolation; LOS null without tri", () => {
|
|
97
|
+
const pkg = deAncientPkg!;
|
|
98
|
+
const mapName = pkg.match.mapName;
|
|
99
|
+
const assets = loadSpatialAssets(mapName); // 匹配 fixture 的实际地图
|
|
100
|
+
expect(assets.available.nav).toBe(true);
|
|
101
|
+
|
|
102
|
+
const windows = buildUtilityWindows(pkg, assets);
|
|
103
|
+
if (assets.available.zones) {
|
|
104
|
+
const zoneRatio = windows.filter((w) => w.zoneId != null).length / windows.length;
|
|
105
|
+
expect(zoneRatio).toBeGreaterThan(0.5);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const u = buildOfficialUtilitySpatial(pkg, assets);
|
|
109
|
+
let isoTotal = 0;
|
|
110
|
+
for (const v of u.values()) {
|
|
111
|
+
if (assets.available.zones) {
|
|
112
|
+
expect(v.actualSmokeProtectedCrossings).toBeNull(); // 无 tri
|
|
113
|
+
expect(v.actualSmokeSightlineDenialSeconds).toBeNull();
|
|
114
|
+
expect(v.actualIncendiaryDisplacementEvents).toBeGreaterThanOrEqual(0);
|
|
115
|
+
}
|
|
116
|
+
isoTotal += v.actualSmokeIsolationSeconds;
|
|
117
|
+
}
|
|
118
|
+
expect(isoTotal).toBeGreaterThanOrEqual(0); // nav 绕路隔离,因地图/比赛可能为零
|
|
119
|
+
});
|
|
120
|
+
});
|