@cs2dak/maps 0.2.1 → 1.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 (54) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +33 -2
  3. package/callout-grid/de_ancient.json +1 -0
  4. package/callout-grid/de_anubis.json +1 -0
  5. package/callout-grid/de_dust2.json +1 -0
  6. package/callout-grid/de_inferno.json +1 -0
  7. package/callout-grid/de_mirage.json +1 -0
  8. package/callout-grid/de_nuke.json +1 -0
  9. package/callout-grid/de_overpass.json +1 -0
  10. package/map-nav/de_ancient.nav.json +1 -0
  11. package/map-nav/de_anubis.nav.json +1 -0
  12. package/map-nav/de_dust2.nav.json +1 -0
  13. package/map-nav/de_inferno.nav.json +1 -0
  14. package/map-nav/de_mirage.nav.json +1 -0
  15. package/map-nav/de_nuke.nav.json +1 -0
  16. package/map-nav/de_overpass.nav.json +1 -0
  17. package/map-routes/de_ancient.json +231 -0
  18. package/map-routes/de_anubis.json +204 -0
  19. package/map-routes/de_dust2.json +153 -0
  20. package/map-routes/de_inferno.json +207 -0
  21. package/map-routes/de_mirage.json +156 -0
  22. package/map-routes/de_nuke.json +63 -0
  23. package/map-routes/de_overpass.json +87 -0
  24. package/package.json +9 -3
  25. package/src/callout-grid-browser.test.ts +36 -0
  26. package/src/callout-grid-browser.ts +31 -0
  27. package/src/callout-grid-node.test.ts +27 -0
  28. package/src/callout-grid-node.ts +28 -0
  29. package/src/callout-grid.test.ts +73 -0
  30. package/src/callout-grid.ts +168 -0
  31. package/src/callout-names.ts +238 -0
  32. package/src/default-positions.test.ts +109 -0
  33. package/src/default-positions.ts +266 -0
  34. package/src/geometry-assets.test.ts +22 -0
  35. package/src/geometry-assets.ts +57 -0
  36. package/src/index.ts +41 -0
  37. package/src/lineups.test.ts +95 -0
  38. package/src/lineups.ts +336 -0
  39. package/src/nav.test.ts +68 -0
  40. package/src/nav.ts +228 -0
  41. package/src/position-candidates.test.ts +62 -0
  42. package/src/position-candidates.ts +183 -0
  43. package/src/radar-grid.ts +66 -0
  44. package/src/route-assets.test.ts +80 -0
  45. package/src/route-assets.ts +23 -0
  46. package/src/routes.test.ts +46 -0
  47. package/src/routes.ts +93 -0
  48. package/src/site-entry-chokes.ts +234 -0
  49. package/src/site-entry-lexicon.ts +131 -0
  50. package/src/tri-assets.ts +72 -0
  51. package/src/visibility.test.ts +36 -0
  52. package/src/visibility.ts +237 -0
  53. package/src/zone-assets.ts +23 -0
  54. package/src/zones.ts +7 -7
@@ -0,0 +1,266 @@
1
+ import { getCalloutTendencies, getPrimaryCalloutRegion, type TacticalRegion } from "./callout-names.js";
2
+
3
+ export interface DefaultPositionGroup {
4
+ /** 展示名(社区叫法),如 "A1"。 */
5
+ name: string;
6
+ /** 归并到该 group 的原始 callout id 列表。 */
7
+ callouts: string[];
8
+ }
9
+
10
+ export interface SideDefaults {
11
+ /** positionGroupId -> group(有序:决定 basis 展示顺序)。 */
12
+ groups: Record<string, DefaultPositionGroup>;
13
+ }
14
+
15
+ export interface MapDefaults {
16
+ /** T 默认位:按开局默认展开方向划分,一个 group 可覆盖连续且战术含义一致的多个 callout。 */
17
+ t: SideDefaults;
18
+ /** CT 默认位:按具体防守站位划分,尽量一个 group 对应一个明确位置。 */
19
+ ct: SideDefaults;
20
+ }
21
+
22
+ export type DefaultPositionSide = "t" | "ct";
23
+
24
+ export interface DefaultPositionGroupMatch extends DefaultPositionGroup {
25
+ id: string;
26
+ }
27
+
28
+ export interface TacticalLocation {
29
+ callout: string | null;
30
+ tendencies: readonly TacticalRegion[];
31
+ primaryRegion: TacticalRegion | null;
32
+ positionGroupId: string | null;
33
+ isDefaultPosition: boolean;
34
+ }
35
+
36
+ export const DEFAULT_POSITION_GROUPS: Record<string, MapDefaults> = {
37
+ de_ancient: {
38
+ t: {
39
+ groups: {
40
+ t_outside: { name: "匪口", callouts: ["Outside"] },
41
+ a_hall: { name: "A厅", callouts: ["MainHall"] },
42
+ mid: { name: "中路/跳台", callouts: ["TSideUpper", "Middle"] },
43
+ b_outer: { name: "B外", callouts: ["Ruins"] },
44
+ b_short: { name: "B小", callouts: ["TSideLower"] },
45
+ },
46
+ },
47
+ ct: {
48
+ groups: {
49
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
50
+ donut: { name: "甜甜圈", callouts: ["SideHall"] },
51
+ mid: { name: "中路", callouts: ["Middle"] },
52
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
53
+ b_alley: { name: "B底线", callouts: ["Alley"] },
54
+ vip: { name: "VIP", callouts: ["House"] },
55
+ black_house: { name: "黑屋", callouts: ["SideEntrance"] },
56
+ },
57
+ },
58
+ },
59
+ de_anubis: {
60
+ t: {
61
+ groups: {
62
+ b_long: { name: "B外", callouts: ["Ruins", "OutsideLong"] },
63
+ mid_bridge: { name: "中桥", callouts: ["Bridge"] },
64
+ street: { name: "匪梯", callouts: ["Street", "TStairs"] },
65
+ canal: { name: "水下", callouts: ["Canal"] },
66
+ t_upper: { name: "匪跳", callouts: ["TSideUpper"] },
67
+ },
68
+ },
69
+ ct: {
70
+ groups: {
71
+ a_main: { name: "A厅", callouts: ["Main"] },
72
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
73
+ a_connector: { name: "A连", callouts: ["Walkway"] },
74
+ a_heaven: { name: "天堂", callouts: ["Heaven"] },
75
+ mid: { name: "中路", callouts: ["Middle"] },
76
+ mid_doors: { name: "中门", callouts: ["MidDoors"] },
77
+ connector: { name: "黑屋", callouts: ["Connector"] },
78
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
79
+ back_b: { name: "B包台上", callouts: ["BackofB"] },
80
+ b_bricks: { name: "B连阳光房", callouts: ["Bricks"] },
81
+ b_connector: { name: "B连", callouts: ["PalaceInterior"] },
82
+ },
83
+ },
84
+ },
85
+ de_dust2: {
86
+ t: {
87
+ groups: {
88
+ a_doors: { name: "A门外/A门", callouts: ["OutsideLong", "LongDoors"] },
89
+ top_mid: { name: "中远", callouts: ["TopofMid"] },
90
+ b1: { name: "B1", callouts: ["LowerTunnel"] },
91
+ b_tunnels: { name: "B洞", callouts: ["OutsideTunnel", "UpperTunnel"] },
92
+ },
93
+ },
94
+ ct: {
95
+ groups: {
96
+ a_long: { name: "A大", callouts: ["LongA"] },
97
+ pit: { name: "大坑", callouts: ["Pit"] },
98
+ catwalk: { name: "A小", callouts: ["Catwalk", "ShortStairs", "ExtendedA"] },
99
+ mid_doors: { name: "中门", callouts: ["MidDoors"] },
100
+ ct_spawn: { name: "警家", callouts: ["CTSpawn", "UnderA"] },
101
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
102
+ b_doors: { name: "B门", callouts: ["BDoors"] },
103
+ },
104
+ },
105
+ },
106
+ de_inferno: {
107
+ t: {
108
+ groups: {
109
+ banana: { name: "香蕉道", callouts: ["Banana"] },
110
+ t_mid: { name: "匪口/中路", callouts: ["LowerMid", "TRamp", "Middle"] },
111
+ second_mid: { name: "侧道", callouts: ["SecondMid"] },
112
+ apartments: { name: "二楼", callouts: ["BackAlley", "Apartments", "Upstairs"] },
113
+ },
114
+ },
115
+ ct: {
116
+ groups: {
117
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
118
+ pit: { name: "大坑", callouts: ["Pit"] },
119
+ quad: { name: "马棚", callouts: ["Quad"] },
120
+ arch: { name: "连接/拱门", callouts: ["Arch"] },
121
+ library: { name: "书房", callouts: ["Library"] },
122
+ top_mid: { name: "中路", callouts: ["TopofMid"] },
123
+ apartments: { name: "二楼", callouts: ["Apartments"] },
124
+ balcony: { name: "阳台", callouts: ["Balcony"] },
125
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
126
+ ruins: { name: "教堂/警家", callouts: ["Ruins"] },
127
+ banana: { name: "香蕉道", callouts: ["Banana"] },
128
+ },
129
+ },
130
+ },
131
+ de_mirage: {
132
+ t: {
133
+ groups: {
134
+ a_ramp: { name: "A1", callouts: ["PalaceAlley", "TRamp"] },
135
+ a_palace: { name: "A二楼", callouts: ["PalaceInterior"] },
136
+ top_mid: { name: "匪口/中远", callouts: ["SideAlley", "TopofMid"] },
137
+ underpass: { name: "下水道", callouts: ["Underpass"] },
138
+ b_apps: { name: "B二楼", callouts: ["House", "BackAlley", "Apartments"] },
139
+ },
140
+ },
141
+ ct: {
142
+ groups: {
143
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
144
+ stairs: { name: "跳台", callouts: ["Stairs"] },
145
+ jungle: { name: "Jungle", callouts: ["Jungle"] },
146
+ vip: { name: "VIP", callouts: ["SnipersNest"] },
147
+ connector: { name: "拱门", callouts: ["Connector"] },
148
+ catwalk: { name: "B小", callouts: ["Catwalk"] },
149
+ ladder: { name: "黑屋", callouts: ["Ladder"] },
150
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
151
+ truck: { name: "白车", callouts: ["Truck"] },
152
+ market: { name: "超市", callouts: ["Shop"] },
153
+ },
154
+ },
155
+ },
156
+ de_nuke: {
157
+ t: {
158
+ groups: {
159
+ outside: { name: "外场", callouts: ["Outside", "Roof", "Silo"] },
160
+ lobby: { name: "匪厅", callouts: ["Lobby", "Squeaky", "Trophy", "Vending"] },
161
+ },
162
+ },
163
+ ct: {
164
+ groups: {
165
+ outside: { name: "外场", callouts: ["Outside"] },
166
+ garage: { name: "大仓", callouts: ["Garage"] },
167
+ mini: { name: "正门", callouts: ["Mini"] },
168
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
169
+ rafters: { name: "三楼横梁", callouts: ["Rafters"] },
170
+ heaven: { name: "三楼", callouts: ["Heaven"] },
171
+ hut: { name: "黄房", callouts: ["Hut"] },
172
+ ramp: { name: "铁板", callouts: ["Ramp"] },
173
+ admin: { name: "铁板三楼下", callouts: ["Admin"] },
174
+ },
175
+ },
176
+ },
177
+ de_overpass: {
178
+ t: {
179
+ groups: {
180
+ underpass: { name: "下水道", callouts: ["Tunnels"] },
181
+ fountain: { name: "喷泉/游乐园", callouts: ["Fountain", "Playground"] },
182
+ b_long: { name: "B外/长管", callouts: ["Alley", "Canal"] },
183
+ short_pipe: { name: "短管/工地", callouts: ["Pipe", "Water"] },
184
+ },
185
+ },
186
+ ct: {
187
+ groups: {
188
+ restroom: { name: "厕所", callouts: ["Restroom", "UpperPark", "LowerPark"] },
189
+ a_site: { name: "A包", callouts: ["BombsiteA"] },
190
+ b_site: { name: "B包", callouts: ["BombsiteB"] },
191
+ walkway: { name: "ABC", callouts: ["Walkway"] },
192
+ snipers_nest: { name: "B二楼", callouts: ["SnipersNest"] },
193
+ water: { name: "工地", callouts: ["Water"] },
194
+ construction: { name: "B小", callouts: ["Construction"] },
195
+ connector: { name: "下水道连接", callouts: ["Connector"] },
196
+ },
197
+ },
198
+ },
199
+ };
200
+
201
+ function sideDefaults(mapName: string, side: DefaultPositionSide): SideDefaults | null {
202
+ return DEFAULT_POSITION_GROUPS[mapName]?.[side] ?? null;
203
+ }
204
+
205
+ function calloutInGroups(defaults: SideDefaults | null, callout: string): string | null {
206
+ if (!defaults) return null;
207
+ for (const [positionGroupId, group] of Object.entries(defaults.groups)) {
208
+ if (group.callouts.includes(callout)) return positionGroupId;
209
+ }
210
+ return null;
211
+ }
212
+
213
+ export function positionGroupOf(mapName: string, side: DefaultPositionSide, callout: string): string | null {
214
+ return calloutInGroups(sideDefaults(mapName, side), callout);
215
+ }
216
+
217
+ export function getDefaultPositionGroup(
218
+ mapName: string,
219
+ side: DefaultPositionSide,
220
+ callout: string,
221
+ ): DefaultPositionGroupMatch | null {
222
+ const defaults = sideDefaults(mapName, side);
223
+ const id = calloutInGroups(defaults, callout);
224
+ if (!defaults || !id) return null;
225
+ return { id, ...defaults.groups[id] };
226
+ }
227
+
228
+ export interface PositionGroupDisplay {
229
+ id: string;
230
+ displayName: string;
231
+ officialName: string | null;
232
+ resolved: boolean;
233
+ }
234
+
235
+ /** Product-neutral display adapter. Unknown ids stay explicit instead of leaking internal ids as labels. */
236
+ export function positionGroupDisplay(mapName: string, side: DefaultPositionSide, positionGroupId: string): PositionGroupDisplay {
237
+ const group = sideDefaults(mapName, side)?.groups[positionGroupId];
238
+ return group
239
+ ? { id: positionGroupId, displayName: group.name, officialName: group.callouts[0] ?? null, resolved: true }
240
+ : { id: positionGroupId, displayName: "未映射位置", officialName: null, resolved: false };
241
+ }
242
+
243
+ /** 只组合 callout 标签与手工默认位置组;推进、争夺、终点等动态语义由 core 时间线推导。 */
244
+ export function classifyTacticalLocation(
245
+ mapName: string,
246
+ side: DefaultPositionSide,
247
+ callout: string | null,
248
+ ): TacticalLocation {
249
+ if (!callout) {
250
+ return {
251
+ callout: null,
252
+ tendencies: [],
253
+ primaryRegion: null,
254
+ positionGroupId: null,
255
+ isDefaultPosition: false,
256
+ };
257
+ }
258
+ const positionGroupId = positionGroupOf(mapName, side, callout);
259
+ return {
260
+ callout,
261
+ tendencies: getCalloutTendencies(mapName, callout) ?? [],
262
+ primaryRegion: getPrimaryCalloutRegion(mapName, callout),
263
+ positionGroupId,
264
+ isDefaultPosition: positionGroupId != null,
265
+ };
266
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getMapGeometry, getMapNav } from "./geometry-assets.js";
3
+ import { ACTIVE_DUTY_MAPS } from "./zones.js";
4
+
5
+ describe("map geometry assets", () => {
6
+ it("ships compact nav assets for every active duty map", () => {
7
+ for (const mapName of ACTIVE_DUTY_MAPS) {
8
+ const nav = getMapNav(mapName);
9
+ expect(nav?.mapName).toBe(mapName);
10
+ expect(nav?.areas.length, mapName).toBeGreaterThan(100);
11
+ expect(nav?.areas.some((area) => area.neighbors.length > 0), mapName).toBe(true);
12
+ }
13
+ });
14
+
15
+ it("exposes geometry with explicit visibility source status", () => {
16
+ const geometry = getMapGeometry("de_dust2");
17
+ expect(geometry?.nav.mapName).toBe("de_dust2");
18
+ expect(geometry?.sources.nav).toBe("awpy-nav-json");
19
+ expect(geometry?.sources.visibility).toBe("missing");
20
+ expect(getMapGeometry("de_cache")).toBeNull();
21
+ });
22
+ });
@@ -0,0 +1,57 @@
1
+ import ancientNav from "../map-nav/de_ancient.nav.json" with { type: "json" };
2
+ import anubisNav from "../map-nav/de_anubis.nav.json" with { type: "json" };
3
+ import dust2Nav from "../map-nav/de_dust2.nav.json" with { type: "json" };
4
+ import infernoNav from "../map-nav/de_inferno.nav.json" with { type: "json" };
5
+ import mirageNav from "../map-nav/de_mirage.nav.json" with { type: "json" };
6
+ import nukeNav from "../map-nav/de_nuke.nav.json" with { type: "json" };
7
+ import overpassNav from "../map-nav/de_overpass.nav.json" with { type: "json" };
8
+ import type { CompactNav } from "./nav.js";
9
+ import type { TriangleBvh } from "./visibility.js";
10
+ import type { ActiveDutyMap } from "./zones.js";
11
+
12
+ export interface MapGeometry {
13
+ mapName: string;
14
+ nav: CompactNav;
15
+ visibility?: TriangleBvh;
16
+ sources: {
17
+ nav: "awpy-nav-json";
18
+ visibility: "awpy-tri-bvh" | "missing";
19
+ };
20
+ }
21
+
22
+ export const MAP_NAV_ASSETS: Record<ActiveDutyMap, CompactNav> = {
23
+ de_ancient: ancientNav as CompactNav,
24
+ de_anubis: anubisNav as CompactNav,
25
+ de_dust2: dust2Nav as CompactNav,
26
+ de_inferno: infernoNav as CompactNav,
27
+ de_mirage: mirageNav as CompactNav,
28
+ de_nuke: nukeNav as CompactNav,
29
+ de_overpass: overpassNav as CompactNav,
30
+ };
31
+
32
+ export function getMapNav(mapName: string): CompactNav | null {
33
+ if (mapName in MAP_NAV_ASSETS) {
34
+ return MAP_NAV_ASSETS[mapName as ActiveDutyMap];
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * 获取指定地图的完整几何资产(nav + visibility)。
41
+ * visibility 在 .tri 文件可用时自动加载 BVH(Node.js 环境),
42
+ * 浏览器环境通过 tri-assets 的 dynamic import 降级为 "missing"。
43
+ */
44
+ export function getMapGeometry(mapName: string, triBvh?: TriangleBvh | null): MapGeometry | null {
45
+ const nav = getMapNav(mapName);
46
+ if (!nav) return null;
47
+ const visibility = triBvh ?? undefined;
48
+ return {
49
+ mapName,
50
+ nav,
51
+ visibility,
52
+ sources: {
53
+ nav: "awpy-nav-json",
54
+ visibility: visibility ? "awpy-tri-bvh" : "missing",
55
+ },
56
+ };
57
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,32 @@
1
1
  export * from "./zones.js";
2
+ export * from "./zone-assets.js";
3
+ export * from "./routes.js";
4
+ export * from "./route-assets.js";
5
+ export * from "./site-entry-chokes.js";
6
+ export * from "./site-entry-lexicon.js";
7
+ export * from "./geometry-assets.js";
8
+ export * from "./nav.js";
9
+ export * from "./visibility.js";
10
+ export * from "./radar-grid.js";
11
+ export * from "./lineups.js";
12
+ export * from "./default-positions.js";
13
+ export * from "./position-candidates.js";
14
+ export {
15
+ CALLOUT_DICT, CALLOUT_MAPS,
16
+ calloutBelongsToRegion, calloutCn,
17
+ getCalloutDefinition, getCalloutTendencies, getPrimaryCalloutRegion,
18
+ type CalloutNameEntry, type TacticalRegion,
19
+ } from "./callout-names.js";
20
+ export {
21
+ calloutAt,
22
+ calloutNear,
23
+ type CalloutGrid,
24
+ type CalloutGridCell,
25
+ type CalloutGridMeta,
26
+ type CalloutAtResult,
27
+ type CalloutNearOptions,
28
+ type CalloutNearResult,
29
+ } from "./callout-grid.js";
2
30
 
3
31
  export interface MapCalibration {
4
32
  mapName: string;
@@ -32,6 +60,19 @@ export function getMapCalibration(mapName: string): MapCalibration | null {
32
60
  return MAP_CALIBRATIONS[mapName] ?? null;
33
61
  }
34
62
 
63
+ export type MapLevel = "upper" | "lower";
64
+
65
+ /** 该地图是否有上下双层雷达(de_nuke / de_vertigo)。 */
66
+ export function hasLowerLevel(calibration: MapCalibration): boolean {
67
+ return calibration.lowerLevelMaxUnits != null;
68
+ }
69
+
70
+ /** 按 z 高度判定实体属于上层还是下层;单层地图恒为 "upper"。 */
71
+ export function levelAt(z: number, calibration: MapCalibration): MapLevel {
72
+ if (calibration.lowerLevelMaxUnits == null) return "upper";
73
+ return z < calibration.lowerLevelMaxUnits ? "lower" : "upper";
74
+ }
75
+
35
76
  export function worldToRadar(
36
77
  point: { x: number; y: number },
37
78
  calibration: MapCalibration
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildLineupClusters } from "./lineups.js";
3
+
4
+ const throwAt = (roundNumber: number, tick: number, x: number, effectX = 500) => ({
5
+ entryId: "d1",
6
+ freezeEndTick: 0,
7
+ roundNumber,
8
+ grenade: "smoke",
9
+ throwerIndex: 0,
10
+ throwTick: tick,
11
+ throwPosition: { x, y: 0, z: 0 },
12
+ effectPosition: { x: effectX, y: 0, z: 0 }
13
+ });
14
+
15
+ describe("buildLineupClusters", () => {
16
+ it("聚合相近投掷并保留按时间排序的投掷证据", () => {
17
+ const clusters = buildLineupClusters({
18
+ mapName: "de_mirage",
19
+ grenades: [throwAt(3, 3000, 10), throwAt(1, 1000, 0), throwAt(5, 5000, 2000, 3000)],
20
+ roundWinners: new Map([["d1:1", "teamA"], ["d1:3", "teamB"], ["d1:5", "teamA"]]),
21
+ throwerTeam: () => "teamA"
22
+ });
23
+ expect(clusters).toHaveLength(2);
24
+ const main = clusters[0]!;
25
+ expect(main.count).toBe(2);
26
+ expect(main.roundNumbers).toEqual([1, 3]);
27
+ expect(main.throws).toEqual([
28
+ { entryId: "d1", roundNumber: 1, tick: 1000, practicePose: null },
29
+ { entryId: "d1", roundNumber: 3, tick: 3000, practicePose: null }
30
+ ]);
31
+ expect(main.winRatePercent).toBe(50);
32
+ });
33
+
34
+ it("无队伍/胜负信息时胜率为 null", () => {
35
+ const clusters = buildLineupClusters({ mapName: "de_mirage", grenades: [throwAt(1, 1000, 0)] });
36
+ expect(clusters[0]!.winRatePercent).toBeNull();
37
+ });
38
+
39
+ it("默认 strict 会区分相同落点但站位偏差大的投掷", () => {
40
+ const clusters = buildLineupClusters({
41
+ mapName: "de_mirage",
42
+ grenades: [
43
+ throwAt(1, 1000, 0, 500),
44
+ throwAt(2, 2000, 180, 720)
45
+ ]
46
+ });
47
+
48
+ expect(clusters).toHaveLength(2);
49
+ });
50
+
51
+ it("loose 只按落点聚合,用于统计大致打哪里", () => {
52
+ const clusters = buildLineupClusters({
53
+ mapName: "de_mirage",
54
+ mode: "loose",
55
+ grenades: [
56
+ throwAt(1, 1000, 0, 500),
57
+ throwAt(2, 2000, 180, 620)
58
+ ]
59
+ });
60
+
61
+ expect(clusters).toHaveLength(1);
62
+ expect(clusters[0]!.count).toBe(2);
63
+ });
64
+
65
+ it("保留练习命令所需的玩家站位视角证据", () => {
66
+ const practicePose = { position: { x: 10, y: 20, z: 30 }, yaw: 90, pitch: -12 };
67
+ const clusters = buildLineupClusters({
68
+ mapName: "de_mirage",
69
+ grenades: [{ ...throwAt(1, 1000, 0, 500), practicePose }]
70
+ });
71
+
72
+ expect(clusters[0]!.throws[0]).toEqual({
73
+ entryId: "d1",
74
+ roundNumber: 1,
75
+ tick: 1000,
76
+ practicePose
77
+ });
78
+ });
79
+
80
+ it("聚合 effectPosition 对应的落点 callout", () => {
81
+ const clusters = buildLineupClusters({
82
+ mapName: "de_mirage",
83
+ grenades: [
84
+ { ...throwAt(1, 1000, 0, 500), effectCallout: "BombsiteA", effectCalloutConfidence: 0.82, effectCalloutSamples: 12 },
85
+ { ...throwAt(2, 2000, 10, 520), effectCallout: "BombsiteA", effectCalloutConfidence: 0.76, effectCalloutSamples: 8 },
86
+ { ...throwAt(3, 3000, 20, 540), effectCallout: "Ramp", effectCalloutConfidence: 0.9, effectCalloutSamples: 20 },
87
+ ]
88
+ });
89
+
90
+ expect(clusters).toHaveLength(1);
91
+ expect(clusters[0]!.effectCallout).toBe("BombsiteA");
92
+ expect(clusters[0]!.effectCalloutConfidence).toBeCloseTo(0.79);
93
+ expect(clusters[0]!.effectCalloutSamples).toBe(20);
94
+ });
95
+ });