@cs2dak/core 0.2.1 → 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 +68 -88
- package/src/index.ts +41 -9
- 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 +26 -149
- 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/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- 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 +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- package/src/workspace.ts +0 -726
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合阶段推导(严格重建 SP1)。设计见 docs/design/rr-model.md §3.1。
|
|
3
|
+
*
|
|
4
|
+
* MVP 确定性派生 freeze / default / take / execute / postPlant / clutch:
|
|
5
|
+
* - freeze : [startTick, freezeEndTick)
|
|
6
|
+
* - postPlant : [plantTick, endTick)(双方通用窗口;side-aware gate 自行区分守包/retake)
|
|
7
|
+
* - clutch : 一方仅剩 1 人存活(另一方 ≥ 1)起,覆盖战斗类阶段
|
|
8
|
+
* - take : T 首次推进出生区外(routeIndex ≥ 1)
|
|
9
|
+
* - execute : 某条动线上 ≥ 2 名 T 逼近包点入口(routeIndex ≥ len-2)
|
|
10
|
+
*
|
|
11
|
+
* 无 positions / routes 时 take/execute 为 null(降级为 default),并在
|
|
12
|
+
* RoundPhaseModel.hasPositions/hasRoutes 标注,供 evidenceQuality 使用。
|
|
13
|
+
*/
|
|
14
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
15
|
+
import { getMapRoutes, routeIndex, type MapRoutes } from "@cs2dak/maps";
|
|
16
|
+
import { createResolverFromPackage } from "../resolve.js";
|
|
17
|
+
import { groupBy } from "../utils.js";
|
|
18
|
+
import type { RoundPhase, RoundPhaseModel } from "./types.js";
|
|
19
|
+
|
|
20
|
+
type Round = DemoPackage["rounds"][number];
|
|
21
|
+
type PositionRow = { roundNumber: number; tick: number; steamId64: string; lastPlaceName?: string | null };
|
|
22
|
+
type Kill = DemoPackage["kills"][number];
|
|
23
|
+
|
|
24
|
+
export function inferRoundPhases(pkg: DemoPackage): Map<number, RoundPhaseModel> {
|
|
25
|
+
const routes = getMapRoutes(pkg.match?.mapName ?? "");
|
|
26
|
+
// Build positions from replay 8Hz stream (v3 replaces positions-1s)
|
|
27
|
+
const positions = buildPositionRows(pkg);
|
|
28
|
+
const hasPositions = positions.length > 0;
|
|
29
|
+
const hasRoutes = routes != null;
|
|
30
|
+
|
|
31
|
+
const teamByPlayer = new Map(pkg.players.map((p) => [p.steamId64, p.teamKey]));
|
|
32
|
+
const teamSize = countByTeam(pkg);
|
|
33
|
+
|
|
34
|
+
const posByRound = groupBy(positions, (row) => row.roundNumber);
|
|
35
|
+
const killsByRound = groupBy(pkg.kills, (k) => k.roundNumber);
|
|
36
|
+
const playerTeams = pkg.players.map((p) => p.teamKey);
|
|
37
|
+
const plantByRound = new Map<number, number>();
|
|
38
|
+
for (const bomb of pkg.bombs) {
|
|
39
|
+
if (bomb.type === "planted" && !plantByRound.has(bomb.roundNumber)) {
|
|
40
|
+
plantByRound.set(bomb.roundNumber, bomb.tick);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const out = new Map<number, RoundPhaseModel>();
|
|
45
|
+
for (const round of pkg.rounds) {
|
|
46
|
+
const tTeam = round.teamASide === "t" ? "teamA" : round.teamBSide === "t" ? "teamB" : null;
|
|
47
|
+
const plantTick = plantByRound.get(round.roundNumber) ?? null;
|
|
48
|
+
const clutchStartTick = computeClutchStart(killsByRound.get(round.roundNumber) ?? [], teamSize, playerTeams);
|
|
49
|
+
const { takeTick, executeTick } = computeTakeExecute(
|
|
50
|
+
round,
|
|
51
|
+
tTeam,
|
|
52
|
+
routes,
|
|
53
|
+
posByRound.get(round.roundNumber) ?? [],
|
|
54
|
+
teamByPlayer,
|
|
55
|
+
plantTick,
|
|
56
|
+
);
|
|
57
|
+
out.set(round.roundNumber, {
|
|
58
|
+
roundNumber: round.roundNumber,
|
|
59
|
+
startTick: round.startTick,
|
|
60
|
+
freezeEndTick: round.freezeEndTick,
|
|
61
|
+
endTick: round.endTick,
|
|
62
|
+
plantTick,
|
|
63
|
+
takeTick,
|
|
64
|
+
executeTick,
|
|
65
|
+
clutchStartTick,
|
|
66
|
+
hasPositions,
|
|
67
|
+
hasRoutes,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function phaseAtTick(model: RoundPhaseModel, tick: number): RoundPhase {
|
|
74
|
+
if (tick < model.freezeEndTick) return "freeze";
|
|
75
|
+
if (model.clutchStartTick != null && tick >= model.clutchStartTick) return "clutch";
|
|
76
|
+
if (model.plantTick != null && tick >= model.plantTick) return "postPlant";
|
|
77
|
+
if (model.executeTick != null && tick >= model.executeTick) return "execute";
|
|
78
|
+
if (model.takeTick != null && tick >= model.takeTick) return "take";
|
|
79
|
+
return "default";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function countByTeam(pkg: DemoPackage): { teamA: number; teamB: number } {
|
|
83
|
+
let teamA = 0;
|
|
84
|
+
let teamB = 0;
|
|
85
|
+
for (const p of pkg.players) {
|
|
86
|
+
if (p.teamKey === "teamA") teamA += 1;
|
|
87
|
+
else if (p.teamKey === "teamB") teamB += 1;
|
|
88
|
+
}
|
|
89
|
+
return { teamA, teamB };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 一方存活降到 1(另一方 ≥ 1)的首个 tick。 */
|
|
93
|
+
function computeClutchStart(kills: Kill[], teamSize: { teamA: number; teamB: number }, playerTeams: string[]): number | null {
|
|
94
|
+
let aliveA = teamSize.teamA;
|
|
95
|
+
let aliveB = teamSize.teamB;
|
|
96
|
+
if (aliveA === 0 || aliveB === 0) return null;
|
|
97
|
+
for (const kill of [...kills].sort((a, b) => a.tick - b.tick)) {
|
|
98
|
+
const victimTeam = playerTeams[kill.victimIndex];
|
|
99
|
+
if (victimTeam === "teamA") aliveA = Math.max(0, aliveA - 1);
|
|
100
|
+
else if (victimTeam === "teamB") aliveB = Math.max(0, aliveB - 1);
|
|
101
|
+
if ((aliveA === 1 && aliveB >= 1) || (aliveB === 1 && aliveA >= 1)) {
|
|
102
|
+
return kill.tick;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function computeTakeExecute(
|
|
109
|
+
round: Round,
|
|
110
|
+
tTeam: "teamA" | "teamB" | null,
|
|
111
|
+
routes: MapRoutes | null,
|
|
112
|
+
rows: PositionRow[],
|
|
113
|
+
teamByPlayer: Map<string, string>,
|
|
114
|
+
plantTick: number | null,
|
|
115
|
+
): { takeTick: number | null; executeTick: number | null } {
|
|
116
|
+
if (!routes || !tTeam || rows.length === 0) return { takeTick: null, executeTick: null };
|
|
117
|
+
|
|
118
|
+
// 按 tick 聚合本回合 freeze 后、plant 前的 T 方 callout。
|
|
119
|
+
const placesByTick = new Map<number, string[]>();
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (teamByPlayer.get(row.steamId64) !== tTeam) continue;
|
|
122
|
+
if (row.tick < round.freezeEndTick) continue;
|
|
123
|
+
if (plantTick != null && row.tick >= plantTick) continue;
|
|
124
|
+
if (!row.lastPlaceName) continue;
|
|
125
|
+
const arr = placesByTick.get(row.tick) ?? [];
|
|
126
|
+
arr.push(row.lastPlaceName);
|
|
127
|
+
placesByTick.set(row.tick, arr);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let takeTick: number | null = null;
|
|
131
|
+
let executeTick: number | null = null;
|
|
132
|
+
for (const tick of [...placesByTick.keys()].sort((a, b) => a - b)) {
|
|
133
|
+
const places = placesByTick.get(tick)!;
|
|
134
|
+
let advanced = false;
|
|
135
|
+
for (const route of routes.routes) {
|
|
136
|
+
const threshold = Math.max(1, route.zones.length - 2);
|
|
137
|
+
let nearSite = 0;
|
|
138
|
+
for (const place of places) {
|
|
139
|
+
const idx = routeIndex(route, place);
|
|
140
|
+
if (idx >= 1) advanced = true;
|
|
141
|
+
if (idx >= threshold) nearSite += 1;
|
|
142
|
+
}
|
|
143
|
+
if (nearSite >= 2 && executeTick == null) executeTick = tick;
|
|
144
|
+
}
|
|
145
|
+
if (advanced && takeTick == null) takeTick = tick;
|
|
146
|
+
if (takeTick != null && executeTick != null) break;
|
|
147
|
+
}
|
|
148
|
+
// execute 蕴含 take
|
|
149
|
+
if (executeTick != null && (takeTick == null || takeTick > executeTick)) takeTick = executeTick;
|
|
150
|
+
return { takeTick, executeTick };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 从 replay 8Hz 流提取回合阶段推导所需的简化位置行。 */
|
|
154
|
+
function buildPositionRows(pkg: DemoPackage): PositionRow[] {
|
|
155
|
+
const resolver = createResolverFromPackage(pkg);
|
|
156
|
+
const replay = pkg.replay;
|
|
157
|
+
if (!replay) return [];
|
|
158
|
+
const placeDict = replay.placeDict ?? [];
|
|
159
|
+
const out: PositionRow[] = [];
|
|
160
|
+
for (const round of replay.rounds) {
|
|
161
|
+
const tickStep = round.tickStep;
|
|
162
|
+
for (const track of round.players) {
|
|
163
|
+
const player = resolver.byIndexOrNull(track.playerIndex);
|
|
164
|
+
if (!player) continue;
|
|
165
|
+
for (let i = 0; i < track.x.length; i++) {
|
|
166
|
+
const placeIdx = track.place[i] ?? -1;
|
|
167
|
+
const lastPlaceName =
|
|
168
|
+
placeIdx >= 0 && placeIdx < placeDict.length ? placeDict[placeIdx] : null;
|
|
169
|
+
out.push({
|
|
170
|
+
roundNumber: round.roundNumber,
|
|
171
|
+
tick: round.startTick + i * tickStep,
|
|
172
|
+
steamId64: player.steamId64,
|
|
173
|
+
lastPlaceName,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { loadDemoPackageFromZip } from "../loader.js";
|
|
5
|
+
import { deriveRRSignals } from "../signals.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SP2 Trade 闭环端到端:de_ancient fixture 含 replay + 现役图动线,
|
|
9
|
+
* 故 strategicIsolationDeaths 应可观测(非 null),接入 rival-rating 的
|
|
10
|
+
* effectiveUntradedDeaths(rr-model.md §3.3)。
|
|
11
|
+
*/
|
|
12
|
+
describe("strategicIsolationDeaths wiring (de_ancient fixture)", () => {
|
|
13
|
+
it("derives an observable (non-null, >= 0) credit for every player", async () => {
|
|
14
|
+
const zip = await readFile(
|
|
15
|
+
fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)),
|
|
16
|
+
);
|
|
17
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
18
|
+
const signals = deriveRRSignals(pkg);
|
|
19
|
+
|
|
20
|
+
expect(signals).toHaveLength(10);
|
|
21
|
+
for (const s of signals) {
|
|
22
|
+
expect(s.trade.strategicIsolationDeaths).not.toBeNull();
|
|
23
|
+
expect(s.trade.strategicIsolationDeaths as number).toBeGreaterThanOrEqual(0);
|
|
24
|
+
// credit 不应超过该选手的死亡数(抵扣项语义上界)
|
|
25
|
+
expect(s.trade.strategicIsolationDeaths as number).toBeLessThanOrEqual(s.trade.deaths);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns null strategicIsolationDeaths when replay is absent (unobservable)", async () => {
|
|
30
|
+
const zip = await readFile(
|
|
31
|
+
fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)),
|
|
32
|
+
);
|
|
33
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
34
|
+
const { replay: _, ...stripped } = pkg;
|
|
35
|
+
const signals = deriveRRSignals(stripped);
|
|
36
|
+
for (const s of signals) expect(s.trade.strategicIsolationDeaths).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -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
|
+
});
|