@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.
Files changed (62) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +46 -53
  8. package/src/index.ts +25 -8
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +27 -1
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/signals.ts +149 -112
  35. package/src/spatial/annotate.test.ts +133 -0
  36. package/src/spatial/annotate.ts +163 -0
  37. package/src/spatial/index.ts +16 -0
  38. package/src/spatial/mapcontrol.test.ts +131 -0
  39. package/src/spatial/mapcontrol.ts +277 -0
  40. package/src/spatial/phase.test.ts +171 -0
  41. package/src/spatial/phase.ts +179 -0
  42. package/src/spatial/trade-closure.test.ts +38 -0
  43. package/src/spatial/types.ts +56 -0
  44. package/src/spatial/utility-geometry.test.ts +88 -0
  45. package/src/spatial/utility-geometry.ts +167 -0
  46. package/src/spatial/utility.integration.test.ts +38 -0
  47. package/src/spatial/utility.test.ts +120 -0
  48. package/src/spatial/utility.ts +399 -0
  49. package/src/tactics/formations.ts +151 -0
  50. package/src/tactics/index.ts +16 -0
  51. package/src/tactics/replay-round-context.ts +96 -0
  52. package/src/tactics/round-facts.ts +406 -0
  53. package/src/tactics/segments.ts +68 -0
  54. package/src/tactics/tactics.test.ts +112 -0
  55. package/src/tactics/types.ts +73 -0
  56. package/src/timeline.ts +73 -52
  57. package/src/utility-facts.test.ts +55 -0
  58. package/src/utility-facts.ts +137 -0
  59. package/src/utils.ts +27 -25
  60. package/src/weapon-highlights.ts +4 -4
  61. package/src/economy.test.ts +0 -37
  62. package/src/economy.ts +0 -57
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Replay } from "@cs2dak/contract";
3
+ import type { MapZones } from "@cs2dak/maps";
4
+ import { annotatePositions, groupSamplesByRoundTick, loadSpatialAssets, type SpatialAssets } from "./annotate.js";
5
+
6
+ function deltaArr(values: number[]): number[] {
7
+ const out: number[] = [];
8
+ let prev = 0;
9
+ for (const v of values) { out.push(v - prev); prev = v; }
10
+ return out;
11
+ }
12
+
13
+ /** Build a replay round with one (or more) player tracks at a single tick. */
14
+ function replayRound(
15
+ tick: number,
16
+ tracks: { playerIdx: number; x: number; y: number; z: number; placeIdx: number }[],
17
+ ): Replay["rounds"][number] {
18
+ return {
19
+ roundNumber: 1,
20
+ startTick: tick,
21
+ tickStep: 8,
22
+ frameCount: 1,
23
+ players: tracks.map((t) => ({
24
+ playerIndex: t.playerIdx,
25
+ x: deltaArr([t.x]), y: deltaArr([t.y]), z: deltaArr([t.z]),
26
+ yaw: deltaArr([0]), pitch: deltaArr([0]),
27
+ hp: [100], armor: [100], money: [800], equipValue: [800],
28
+ place: [t.placeIdx], flash: [0],
29
+ flags: [1],
30
+ weapon: [-1],
31
+ grenades: [[]],
32
+ })),
33
+ projectiles: [],
34
+ };
35
+ }
36
+
37
+ function pkgWith(mapName: string, rows: { place: string; x: number; y: number; z: number }[]): DemoPackage {
38
+ const placeDict = [...new Set(rows.map((r) => r.place))];
39
+ const placeMap = new Map(placeDict.map((p, i) => [p, i]));
40
+ const tracks = rows.map((r, i) => ({
41
+ playerIdx: Math.min(i, 1), // player 0 or 1
42
+ x: r.x, y: r.y, z: r.z,
43
+ placeIdx: placeMap.get(r.place) ?? -1,
44
+ }));
45
+ return {
46
+ match: { mapName, tickrate: 64 },
47
+ players: [
48
+ { steamId64: "T1", teamKey: "teamA" },
49
+ rows.length > 1 ? { steamId64: "T2", teamKey: "teamA" } : { steamId64: "T2", teamKey: "teamA" },
50
+ ],
51
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
52
+ replay: {
53
+ meta: { sampleRate: 8, tickrate: 64, coordScale: 1, angleScale: 10 },
54
+ weaponDict: [],
55
+ placeDict,
56
+ rounds: [replayRound(120, tracks)],
57
+ },
58
+ } as unknown as DemoPackage;
59
+ }
60
+
61
+ /** Create a package with no replay data (simulates unobservable positions). */
62
+ function pkgWithoutReplay(): DemoPackage {
63
+ return {
64
+ match: { mapName: "de_dust2", tickrate: 64 },
65
+ players: [{ steamId64: "T1", teamKey: "teamA" }],
66
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
67
+ } as unknown as DemoPackage;
68
+ }
69
+
70
+ describe("loadSpatialAssets", () => {
71
+ it("reports availability per asset for an active-duty map", () => {
72
+ const assets = loadSpatialAssets("de_dust2");
73
+ expect(assets.available.routes).toBe(true);
74
+ expect(assets.available.nav).toBe(true);
75
+ expect(assets.available.visibility).toBe(false); // no .tri passed
76
+ });
77
+
78
+ it("reports everything missing for an unknown map", () => {
79
+ const assets = loadSpatialAssets("de_unknown");
80
+ expect(assets.available.routes).toBe(false);
81
+ expect(assets.available.nav).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("annotatePositions", () => {
86
+ it("attaches callout and position from replay when nav is available", () => {
87
+ const assets = loadSpatialAssets("de_dust2");
88
+ const samples = annotatePositions(pkgWith("de_dust2", [{ place: "LongA", x: 760, y: 1660, z: 0 }]), assets);
89
+ expect(samples).toHaveLength(1);
90
+ expect(samples[0]!.callout).toBe("LongA");
91
+ expect(samples[0]!.position).toEqual({ x: 760, y: 1660, z: 0 });
92
+ });
93
+
94
+ it("leaves navAreaId null when nav is unavailable", () => {
95
+ const assets = loadSpatialAssets("de_unknown");
96
+ const samples = annotatePositions(pkgWith("de_unknown", [{ place: "Somewhere", x: 0, y: 0, z: 0 }]), assets);
97
+ expect(samples).toHaveLength(1);
98
+ expect(samples[0]!.navAreaId).toBeNull();
99
+ expect(samples[0]!.callout).toBe("Somewhere");
100
+ });
101
+
102
+ it("returns an empty array without positions", () => {
103
+ const assets = loadSpatialAssets("de_dust2");
104
+ expect(annotatePositions(pkgWithoutReplay(), assets)).toEqual([]);
105
+ });
106
+
107
+ it("groups samples by round then tick", () => {
108
+ const assets = loadSpatialAssets("de_dust2");
109
+ const samples = annotatePositions(
110
+ pkgWith("de_dust2", [{ place: "LongA", x: 760, y: 1660, z: 0 }, { place: "TSpawn", x: -500, y: -850, z: 96 }]),
111
+ assets,
112
+ );
113
+ const grouped = groupSamplesByRoundTick(samples);
114
+ expect(grouped.get(1)!.get(120)!).toHaveLength(2);
115
+ });
116
+
117
+ it("populates zoneId/zoneRole/zoneBombsite via zoneAt when zones are available", () => {
118
+ const TEST_ZONES: MapZones = {
119
+ mapName: "de_test",
120
+ version: "t",
121
+ zones: [{ id: "a_site", name: "A", role: "site", bombsite: "a", polygon: [[0, 0], [100, 0], [100, 100], [0, 100]] }],
122
+ };
123
+ const assets: SpatialAssets = {
124
+ mapName: "de_test", routes: null, zones: TEST_ZONES, nav: null, visibility: null,
125
+ available: { routes: false, zones: true, nav: false, visibility: false },
126
+ };
127
+ const samples = annotatePositions(pkgWith("de_test", [{ place: "A", x: 50, y: 50, z: 0 }]), assets);
128
+ expect(samples).toHaveLength(1);
129
+ expect(samples[0]!.zoneId).toBe("a_site");
130
+ expect(samples[0]!.zoneRole).toBe("site");
131
+ expect(samples[0]!.zoneBombsite).toBe("a");
132
+ });
133
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Raw evidence 地基:位置标注 + 空间资产装载(严格重建 SP1)。
3
+ * 设计见 docs/design/rr-model.md §3、§5(计算流程)。
4
+ *
5
+ * 当前控图/战术语义真相源是 replay place 生成的 callout grid,不是手标 zone polygon。
6
+ * zone 字段只保留给旧 shadow spatial/utility 几何路径;新增控图 finding 不应消费 zoneId。
7
+ * 这些是 official MapControl gate(SP2:solo pressure 的 nav 距离、denial 的 LOS)的输入底座。
8
+ *
9
+ * v3 replay 8Hz 流替代了 v2 的 positions-1s。每帧解码后转换:
10
+ * - track.tick(差分编码 → decodeDelta)
11
+ * - track.x/y/z(差分编码 × coordScale → 游戏单位)
12
+ * - track.place(索引 → placeDict)
13
+ * - track.flags(FLAG_ALIVE)
14
+ * - track.hp(原始值)
15
+ */
16
+ import type { DemoPackage } from "@cs2dak/contract";
17
+ import { decodeDelta, FLAG_ALIVE } from "@cs2dak/contract";
18
+ import { createResolverFromPackage } from "../resolve.js";
19
+ import {
20
+ getMapNav,
21
+ getMapRoutes,
22
+ getMapZones,
23
+ zoneAt,
24
+ type CompactNav,
25
+ type MapRoutes,
26
+ type MapZones,
27
+ type TriangleBvh,
28
+ type Vec3,
29
+ type ZoneRole,
30
+ } from "@cs2dak/maps";
31
+
32
+ export interface SpatialAssets {
33
+ mapName: string;
34
+ routes: MapRoutes | null;
35
+ zones: MapZones | null;
36
+ nav: CompactNav | null;
37
+ /** 静态视线 BVH;Node 下经 tri-assets 装载,浏览器降级为 null。 */
38
+ visibility: TriangleBvh | null;
39
+ available: {
40
+ routes: boolean;
41
+ zones: boolean;
42
+ nav: boolean;
43
+ visibility: boolean;
44
+ };
45
+ }
46
+
47
+ export function loadSpatialAssets(mapName: string, triBvh?: TriangleBvh | null): SpatialAssets {
48
+ const routes = getMapRoutes(mapName);
49
+ const zones = getMapZones(mapName);
50
+ const nav = getMapNav(mapName);
51
+ const visibility = triBvh ?? null;
52
+ return {
53
+ mapName,
54
+ routes,
55
+ zones,
56
+ nav,
57
+ visibility,
58
+ available: {
59
+ routes: routes != null,
60
+ zones: zones != null,
61
+ nav: nav != null,
62
+ visibility: visibility != null,
63
+ },
64
+ };
65
+ }
66
+
67
+ export interface AnnotatedSample {
68
+ roundNumber: number;
69
+ tick: number;
70
+ steamId64: string;
71
+ teamKey: string;
72
+ side: string | null;
73
+ alive: boolean;
74
+ position: Vec3;
75
+ /** callout(replay placeDict);缺失为 null。 */
76
+ callout: string | null;
77
+ /** legacy 手标 zone id;当前控图/战术语义不要把它当真相源。 */
78
+ zoneId: string | null;
79
+ /** zone 语义角色(site/mid/connector/lane…);无 zone 为 null。 */
80
+ zoneRole: ZoneRole | null;
81
+ /** zone 关联包点;无则 null。 */
82
+ zoneBombsite: "a" | "b" | null;
83
+ /** 最近 nav area id;无 nav 资产为 null。 */
84
+ navAreaId: number | null;
85
+ /** 当前血量(火焰逼退掉血判定用);缺失为 100。 */
86
+ health: number;
87
+ }
88
+
89
+ /**
90
+ * 从 replay 8Hz 流标注位置样本(v3 替代 v2 positions-1s)。
91
+ * 返回所有存活/阵亡玩家的逐帧标注,zone/nav 判定暂缺(需 maps 包导出 zoneAt/nearestNavArea)。
92
+ */
93
+ export function annotatePositions(pkg: DemoPackage, assets: SpatialAssets): AnnotatedSample[] {
94
+ const resolver = createResolverFromPackage(pkg);
95
+ const replay = pkg.replay;
96
+ if (!replay) return [];
97
+
98
+ const coordScale = replay.meta.coordScale;
99
+ const placeDict = replay.placeDict ?? [];
100
+ const samples: AnnotatedSample[] = [];
101
+
102
+ const roundByNumber = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
103
+
104
+ for (const round of replay.rounds) {
105
+ const roundRow = roundByNumber.get(round.roundNumber);
106
+ if (!roundRow) continue;
107
+ const tickStep = round.tickStep;
108
+
109
+ for (const track of round.players) {
110
+ const player = resolver.byIndexOrNull(track.playerIndex);
111
+ if (!player) continue;
112
+
113
+ const xs = decodeDelta(track.x);
114
+ const ys = decodeDelta(track.y);
115
+ const zs = decodeDelta(track.z);
116
+ const side: string | null =
117
+ player.teamKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
118
+
119
+ for (let i = 0; i < xs.length; i++) {
120
+ const placeIdx = track.place[i] ?? -1;
121
+ const px = (xs[i] ?? 0) * coordScale;
122
+ const py = (ys[i] ?? 0) * coordScale;
123
+ const pz = (zs[i] ?? 0) * coordScale;
124
+ const zone = assets.zones ? zoneAt(assets.zones, px, py, pz) : null;
125
+ samples.push({
126
+ roundNumber: round.roundNumber,
127
+ tick: round.startTick + i * tickStep,
128
+ steamId64: player.steamId64,
129
+ teamKey: player.teamKey,
130
+ side,
131
+ alive: ((track.flags[i] ?? 0) & FLAG_ALIVE) !== 0,
132
+ position: { x: px, y: py, z: pz },
133
+ callout: placeIdx >= 0 && placeIdx < placeDict.length ? placeDict[placeIdx] : null,
134
+ zoneId: zone?.id ?? null,
135
+ zoneRole: (zone?.role as ZoneRole | undefined) ?? null,
136
+ zoneBombsite: (zone?.bombsite as "a" | "b" | undefined) ?? null,
137
+ navAreaId: null,
138
+ health: track.hp[i] ?? 100,
139
+ });
140
+ }
141
+ }
142
+ }
143
+
144
+ return samples;
145
+ }
146
+
147
+ /** 按 (round, tick) 分组的标注样本,供逐帧 gate 消费。 */
148
+ export function groupSamplesByRoundTick(
149
+ samples: AnnotatedSample[],
150
+ ): Map<number, Map<number, AnnotatedSample[]>> {
151
+ const out = new Map<number, Map<number, AnnotatedSample[]>>();
152
+ for (const s of samples) {
153
+ let byTick = out.get(s.roundNumber);
154
+ if (!byTick) {
155
+ byTick = new Map();
156
+ out.set(s.roundNumber, byTick);
157
+ }
158
+ const arr = byTick.get(s.tick) ?? [];
159
+ arr.push(s);
160
+ byTick.set(s.tick, arr);
161
+ }
162
+ return out;
163
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 空间分析(严格重建)。两层:raw evidence(宽,复盘/shadow) → official gated(窄,进 RR)。
3
+ * 设计与边界见 docs/design/rr-model.md §3。
4
+ *
5
+ * SP1(已落地):RoundPhase 模型 + 位置标注地基。
6
+ * SP2/SP3(计划):official MapControl / UtilitySpatial gates + strategicIsolationDeathCredits。
7
+ */
8
+ export type { RoundPhase, RoundPhaseModel } from "./types.js";
9
+ export { OFFICIAL_EXCLUDED_PHASES, isOfficialScoringPhase } from "./types.js";
10
+ export { inferRoundPhases, phaseAtTick } from "./phase.js";
11
+ export type { SpatialAssets, AnnotatedSample } from "./annotate.js";
12
+ export { loadSpatialAssets, annotatePositions, groupSamplesByRoundTick } from "./annotate.js";
13
+ export type { OfficialMapControl } from "./mapcontrol.js";
14
+ export { buildOfficialMapControl } from "./mapcontrol.js";
15
+ export type { OfficialUtilitySpatial, UtilityWindow, GrenadeType } from "./utility.js";
16
+ export { buildOfficialUtilitySpatial, buildUtilityWindows } from "./utility.js";
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Replay } from "@cs2dak/contract";
3
+ import { loadSpatialAssets } from "./annotate.js";
4
+ import { buildOfficialMapControl } from "./mapcontrol.js";
5
+
6
+ function deltaArr(values: number[]): number[] {
7
+ const out: number[] = [];
8
+ let prev = 0;
9
+ for (const v of values) { out.push(v - prev); prev = v; }
10
+ return out;
11
+ }
12
+
13
+ function replayTracks(t1Places: string[], idxByPlace: Map<string, number>) {
14
+ return [
15
+ {
16
+ playerIndex: 0,
17
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
18
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
19
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
20
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
21
+ weapon: [-1, -1, -1, -1],
22
+ place: t1Places.map((p) => idxByPlace.get(p) ?? -1),
23
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
24
+ },
25
+ {
26
+ playerIndex: 1,
27
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
28
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
29
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
30
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
31
+ weapon: [-1, -1, -1, -1],
32
+ place: [idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!],
33
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
34
+ },
35
+ {
36
+ playerIndex: 2,
37
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
38
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
39
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
40
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
41
+ weapon: [-1, -1, -1, -1],
42
+ place: [idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!],
43
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
44
+ },
45
+ {
46
+ playerIndex: 3,
47
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
48
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
49
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
50
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
51
+ weapon: [-1, -1, -1, -1],
52
+ place: [idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!],
53
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
54
+ },
55
+ ];
56
+ }
57
+
58
+ /** T1 独推 a_long(LongDoors→ARamp),T2 留 TSpawn,C1 守 BombsiteA(同线施压),C2 离线。 */
59
+ function makePkg(over: { kills?: unknown[] } = {}): DemoPackage {
60
+ const t1 = ["LongDoors", "LongA", "ARamp", "ARamp"];
61
+ const allPlaces = [...new Set([...t1, "TSpawn", "BombsiteA", "CTSpawn"])];
62
+ const idxByPlace = new Map(allPlaces.map((p, i) => [p, i]));
63
+ return {
64
+ match: { mapName: "de_dust2", tickrate: 64 },
65
+ players: [
66
+ { steamId64: "T1", teamKey: "teamA" },
67
+ { steamId64: "T2", teamKey: "teamA" },
68
+ { steamId64: "C1", teamKey: "teamB" },
69
+ { steamId64: "C2", teamKey: "teamB" },
70
+ ],
71
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
72
+ bombs: [],
73
+ kills: over.kills ?? [],
74
+ replay: {
75
+ meta: { sampleRate: 1, tickrate: 64, coordScale: 1, angleScale: 10 },
76
+ weaponDict: [],
77
+ placeDict: allPlaces,
78
+ rounds: [{
79
+ roundNumber: 1,
80
+ startTick: 150,
81
+ tickStep: 64,
82
+ frameCount: 4,
83
+ players: replayTracks(t1, idxByPlace) as unknown as Replay["rounds"][number]["players"],
84
+ projectiles: [],
85
+ }],
86
+ },
87
+ } as unknown as DemoPackage;
88
+ }
89
+
90
+ const untradedDeath = [{ roundNumber: 1, tick: 410, killerIndex: 2, victimIndex: 0, victimSteamId64: "T1", victimTeamKey: "teamA", killerSteamId64: "C1", killerTeamKey: "teamB", tradeDeath: false, weapon: "ak47", headshot: false }];
91
+
92
+ describe("buildOfficialMapControl", () => {
93
+ it("accumulates activeSoloPressureSeconds for a lone route pusher with enemy on the lane", () => {
94
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_dust2"));
95
+ expect(mc.get("T1")!.activeSoloPressureSeconds).toBe(4); // 4 个 1Hz 样本
96
+ expect(mc.get("T2")?.activeSoloPressureSeconds ?? 0).toBe(0); // 始终在 TSpawn,未上线
97
+ });
98
+
99
+ it("accumulates denial for the defending anchor and firstControl for the pusher", () => {
100
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_dust2"));
101
+ // C1 守 BombsiteA(a_long 末端)、T1 同线施压、无 plant → CT 防守 → C1 计 denial
102
+ expect(mc.get("C1")!.sidePhaseAwareDenialSeconds).toBeGreaterThan(0);
103
+ // T1 推进到 a_long 关键段(LongA idx3 ≥ ceil(6×0.4)=3)→ firstControl 一次
104
+ expect(mc.get("T1")!.firstMeaningfulControlEvents).toBe(1);
105
+ // T1 是进攻方,非防守 → 无 denial
106
+ expect(mc.get("T1")!.sidePhaseAwareDenialSeconds).toBe(0);
107
+ });
108
+
109
+ it("awards strategicIsolationDeaths credit for an untraded death after sustained solo pressure", () => {
110
+ const mc = buildOfficialMapControl(makePkg({ kills: untradedDeath }), loadSpatialAssets("de_dust2"));
111
+ // credit = clamp(0.25 + 0.10×4, 0, 1) = 0.65
112
+ expect(mc.get("T1")!.strategicIsolationDeaths).toBeCloseTo(0.65, 3);
113
+ });
114
+
115
+ it("gives no credit when the death was traded", () => {
116
+ const traded = [{ ...untradedDeath[0], tradeDeath: true }];
117
+ const mc = buildOfficialMapControl(makePkg({ kills: traded }), loadSpatialAssets("de_dust2"));
118
+ expect(mc.get("T1")?.strategicIsolationDeaths ?? 0).toBe(0);
119
+ });
120
+
121
+ it("gives no credit when there was no prior solo pressure (TSpawn camper)", () => {
122
+ const death = [{ roundNumber: 1, tick: 410, killerIndex: 2, victimIndex: 1, victimSteamId64: "T2", victimTeamKey: "teamA", killerSteamId64: "C1", killerTeamKey: "teamB", tradeDeath: false, weapon: "ak47", headshot: false }];
123
+ const mc = buildOfficialMapControl(makePkg({ kills: death }), loadSpatialAssets("de_dust2"));
124
+ expect(mc.get("T2")?.strategicIsolationDeaths ?? 0).toBe(0);
125
+ });
126
+
127
+ it("returns an empty map without route assets", () => {
128
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_unknown"));
129
+ expect(mc.size).toBe(0);
130
+ });
131
+ });