@cs2dak/maps 0.2.0 → 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
package/src/lineups.ts ADDED
@@ -0,0 +1,336 @@
1
+ import type { Vec3 } from "./nav.js";
2
+
3
+ /** CS2 竞技模式标准回合时长(秒,freeze 结束后)。 */
4
+ const ROUND_DURATION_SECONDS = 115;
5
+
6
+ export type LineupClusterMode = "loose" | "strict";
7
+
8
+ type GrenadeClusterKind = "smoke" | "molotov" | "incendiary" | "flashbang" | "he";
9
+
10
+ export const GRENADE_CLUSTER_PRESETS = {
11
+ loose: {
12
+ landingRadius: {
13
+ smoke: 150,
14
+ molotov: 180,
15
+ incendiary: 170,
16
+ flashbang: 220,
17
+ he: 220,
18
+ },
19
+ originRadius: null,
20
+ },
21
+ strict: {
22
+ landingRadius: {
23
+ smoke: 96,
24
+ molotov: 120,
25
+ incendiary: 112,
26
+ flashbang: 160,
27
+ he: 160,
28
+ },
29
+ originRadius: {
30
+ smoke: 64,
31
+ molotov: 64,
32
+ incendiary: 64,
33
+ flashbang: 80,
34
+ he: 80,
35
+ },
36
+ },
37
+ } as const;
38
+
39
+ export interface LineupPracticePose {
40
+ position: Vec3;
41
+ yaw: number;
42
+ pitch: number;
43
+ }
44
+
45
+ export interface LineupThrowEvidence {
46
+ entryId: string;
47
+ roundNumber: number;
48
+ tick: number;
49
+ practicePose?: LineupPracticePose | null;
50
+ }
51
+
52
+ export interface LineupGrenadeLike {
53
+ roundNumber: number;
54
+ grenade: string;
55
+ throwerIndex: number;
56
+ throwTick: number;
57
+ throwPosition: Vec3;
58
+ effectPosition: Vec3;
59
+ /** 投掷 tick 的玩家站位/视角,用于生成跑图练习命令。 */
60
+ practicePose?: LineupPracticePose | null;
61
+ /** 跨场聚类所需:区分各 demo 的 grenade 来源。 */
62
+ entryId: string;
63
+ /** 回合 freezeEndTick,用于计算投掷时间(throwTick - freezeEndTick → 秒)。 */
64
+ freezeEndTick: number;
65
+ /** 投掷时 thrower 所在的 callout 名(从 replay placeDict 解析)。 */
66
+ throwerPlaceName?: string | null;
67
+ /** 落点/生效点对应的 callout 名(从 3D callout grid 解析)。 */
68
+ effectCallout?: string | null;
69
+ /** effectCallout 的多数表决置信度。 */
70
+ effectCalloutConfidence?: number | null;
71
+ /** effectCallout 所在格采样数。 */
72
+ effectCalloutSamples?: number | null;
73
+ /** thrower 所在方。 */
74
+ side?: "t" | "ct" | null;
75
+ /**
76
+ * thrower 的队伍 key("teamA" / "teamB"),跨场聚类时替代 throwerTeam 回调。
77
+ * 单场场景下 throwerTeam 回调也能用,但跨场后 playerIndex 不复跨场有效。
78
+ */
79
+ teamKey?: string | null;
80
+ }
81
+
82
+ export interface LineupCluster {
83
+ id: string;
84
+ mapName: string;
85
+ mode: LineupClusterMode;
86
+ grenade: string;
87
+ throwPosition: Vec3;
88
+ effectPosition: Vec3;
89
+ count: number;
90
+ roundNumbers: number[];
91
+ throwerIndices: number[];
92
+ /** 聚类成员的投掷证据(demo + 回合 + throwTick),按时间排序;[0] 即最早一次投掷。 */
93
+ throws: LineupThrowEvidence[];
94
+ winRatePercent: number | null;
95
+ /** 跨场记录:哪些 demo 含该 lineup。 */
96
+ entryIds: string[];
97
+ /** 跨场涉及的 demo 数(按 entryId 去重)。 */
98
+ demoCount: number;
99
+ /** 最高频投掷时间段(10 秒桶,如 "10-20s"),基于 freezeEndTick 偏移。 */
100
+ throwTimeBucket: string | null;
101
+ /** 最高频投掷位 callout(从 replay placeDict 取 thrower 位置)。 */
102
+ throwerPlaceName: string | null;
103
+ /** 最高频落点 callout(从 3D callout grid 取 effectPosition 位置)。 */
104
+ effectCallout: string | null;
105
+ /** 同簇内落点 callout 置信度均值。 */
106
+ effectCalloutConfidence: number | null;
107
+ /** 同簇内落点 callout 采样总数。 */
108
+ effectCalloutSamples: number | null;
109
+ /** 最高频 side。 */
110
+ side: "t" | "ct" | null;
111
+ }
112
+
113
+ export interface BuildLineupClustersOptions {
114
+ mapName: string;
115
+ grenades: LineupGrenadeLike[];
116
+ /** key: `${entryId}:${roundNumber}`,跨场场景下每场 roundNumber 会重复,需加 entryId 前缀。 */
117
+ roundWinners?: Map<string, string>;
118
+ throwerTeam?: (throwerIndex: number) => string | null;
119
+ throwToleranceUnits?: number;
120
+ effectToleranceUnits?: number;
121
+ /** strict=固定道具学习;loose=只看大致落点/区域频率。默认 strict。 */
122
+ mode?: LineupClusterMode;
123
+ /** tickrate,用于将 freeEndTick 偏移量转换为秒。默认 64。 */
124
+ tickrate?: number;
125
+ }
126
+
127
+ /**
128
+ * 按投掷位置 + 落点位置的空间容差聚类。跨场聚类应由调用方合并
129
+ * 所有 entry 的 grenades 后单次调用,不可每场各调一次再 flat 拼合。
130
+ *
131
+ * throwerPlaceName 通过 replay player track 的 place 字段解析;
132
+ * effectCallout 来自 Studio 3D callout grid,多数表决后进入簇摘要。
133
+ */
134
+ export function buildLineupClusters({
135
+ mapName,
136
+ grenades,
137
+ roundWinners,
138
+ throwerTeam,
139
+ throwToleranceUnits,
140
+ effectToleranceUnits,
141
+ mode = "strict",
142
+ tickrate = 64
143
+ }: BuildLineupClustersOptions): LineupCluster[] {
144
+ const clusters: Array<
145
+ LineupCluster & { wins: number; teamRounds: number }
146
+ > = [];
147
+ // 辅助 map:记录 throwTimeBucket 的频次分布,用于取 mode
148
+ const bucketCounts: Array<Map<string, number>> = [];
149
+ const placeCounts: Array<Map<string, number>> = [];
150
+ const effectCalloutCounts: Array<Map<string, number>> = [];
151
+ const effectCalloutStats: Array<Map<string, { confidenceSum: number; confidenceCount: number; samples: number }>> = [];
152
+ const sideCounts: Array<Map<string, number>> = [];
153
+
154
+ for (const grenade of grenades) {
155
+ const effectTolerance = effectToleranceUnits ?? landingRadiusFor(mode, grenade.grenade);
156
+ const throwTolerance = throwToleranceUnits ?? originRadiusFor(mode, grenade.grenade);
157
+ const existing = clusters.find((cluster) =>
158
+ cluster.grenade === grenade.grenade &&
159
+ (throwTolerance == null || distance(cluster.throwPosition, grenade.throwPosition) <= throwTolerance) &&
160
+ distance(cluster.effectPosition, grenade.effectPosition) <= effectTolerance
161
+ );
162
+ const team = throwerTeam?.(grenade.throwerIndex) ?? grenade.teamKey ?? null;
163
+ const winner = roundWinners?.get(`${grenade.entryId}:${grenade.roundNumber}`) ?? null;
164
+ const won = team != null && winner === team;
165
+ const counted = team != null && winner != null;
166
+ const target = existing ?? {
167
+ id: `${mapName}:${mode}:${grenade.grenade}:${clusters.length + 1}`,
168
+ mapName,
169
+ mode,
170
+ grenade: grenade.grenade,
171
+ throwPosition: grenade.throwPosition,
172
+ effectPosition: grenade.effectPosition,
173
+ count: 0,
174
+ roundNumbers: [],
175
+ throwerIndices: [],
176
+ throws: [],
177
+ winRatePercent: null,
178
+ wins: 0,
179
+ teamRounds: 0,
180
+ entryIds: [],
181
+ demoCount: 0,
182
+ throwTimeBucket: null,
183
+ throwerPlaceName: null,
184
+ effectCallout: null,
185
+ effectCalloutConfidence: null,
186
+ effectCalloutSamples: null,
187
+ side: null,
188
+ };
189
+
190
+ const idx = existing ? clusters.indexOf(existing) : clusters.length;
191
+ if (!existing) {
192
+ bucketCounts.push(new Map());
193
+ placeCounts.push(new Map());
194
+ effectCalloutCounts.push(new Map());
195
+ effectCalloutStats.push(new Map());
196
+ sideCounts.push(new Map());
197
+ }
198
+
199
+ // 基础计数
200
+ target.count += 1;
201
+ target.roundNumbers.push(grenade.roundNumber);
202
+ target.throwerIndices.push(grenade.throwerIndex);
203
+ target.throws.push({
204
+ entryId: grenade.entryId,
205
+ roundNumber: grenade.roundNumber,
206
+ tick: grenade.throwTick,
207
+ practicePose: grenade.practicePose ?? null,
208
+ });
209
+ target.throwPosition = averagePoint(target.throwPosition, grenade.throwPosition, target.count);
210
+ target.effectPosition = averagePoint(target.effectPosition, grenade.effectPosition, target.count);
211
+ if (counted) target.teamRounds += 1;
212
+ if (won) target.wins += 1;
213
+
214
+ // 跨场条目追踪
215
+ if (!target.entryIds.includes(grenade.entryId)) {
216
+ target.entryIds.push(grenade.entryId);
217
+ }
218
+ target.demoCount = target.entryIds.length;
219
+
220
+ // 投掷时间:10 秒桶 → 倒计时(距 freezeEnd 的剩余秒数,CS2 标准 1:55 回合)
221
+ if (grenade.freezeEndTick > 0) {
222
+ const secondsSinceFreeze = (grenade.throwTick - grenade.freezeEndTick) / tickrate;
223
+ if (secondsSinceFreeze >= 0) {
224
+ const bucketStart = Math.floor(secondsSinceFreeze / 10) * 10;
225
+ const midpoint = bucketStart + 5; // 桶中值,如 10-20s 桶取 15s
226
+ const remaining = Math.max(0, ROUND_DURATION_SECONDS - midpoint);
227
+ const label = `${Math.floor(remaining / 60)}:${String(Math.round(remaining % 60)).padStart(2, "0")}`;
228
+ const bc = bucketCounts[idx];
229
+ bc.set(label, (bc.get(label) ?? 0) + 1);
230
+ }
231
+ }
232
+
233
+ // 投掷位 callout
234
+ if (grenade.throwerPlaceName) {
235
+ const pc = placeCounts[idx];
236
+ pc.set(grenade.throwerPlaceName, (pc.get(grenade.throwerPlaceName) ?? 0) + 1);
237
+ }
238
+
239
+ // 落点 callout
240
+ if (grenade.effectCallout) {
241
+ const ec = effectCalloutCounts[idx];
242
+ ec.set(grenade.effectCallout, (ec.get(grenade.effectCallout) ?? 0) + 1);
243
+ const statsByCallout = effectCalloutStats[idx];
244
+ const stats = statsByCallout.get(grenade.effectCallout) ?? { confidenceSum: 0, confidenceCount: 0, samples: 0 };
245
+ if (typeof grenade.effectCalloutConfidence === "number") {
246
+ stats.confidenceSum += grenade.effectCalloutConfidence;
247
+ stats.confidenceCount += 1;
248
+ }
249
+ if (typeof grenade.effectCalloutSamples === "number") {
250
+ stats.samples += grenade.effectCalloutSamples;
251
+ }
252
+ statsByCallout.set(grenade.effectCallout, stats);
253
+ }
254
+
255
+ // side
256
+ if (grenade.side) {
257
+ const sc = sideCounts[idx];
258
+ sc.set(grenade.side, (sc.get(grenade.side) ?? 0) + 1);
259
+ }
260
+
261
+ if (!existing) clusters.push(target);
262
+ }
263
+
264
+ return clusters
265
+ .map((cluster, i) => {
266
+ const effectCallout = modeOfMap(effectCalloutCounts[i]);
267
+ const effectStats = effectCallout ? effectCalloutStats[i].get(effectCallout) : undefined;
268
+ return {
269
+ ...cluster,
270
+ roundNumbers: [...new Set(cluster.roundNumbers)].sort((a, b) => a - b),
271
+ throwerIndices: [...new Set(cluster.throwerIndices)].sort((a, b) => a - b),
272
+ throws: [...cluster.throws].sort((a, b) =>
273
+ a.entryId.localeCompare(b.entryId) || a.roundNumber - b.roundNumber || a.tick - b.tick
274
+ ),
275
+ winRatePercent: cluster.teamRounds > 0
276
+ ? Math.round((cluster.wins / cluster.teamRounds) * 1000) / 10
277
+ : null,
278
+ entryIds: [...new Set(cluster.entryIds)].sort(),
279
+ demoCount: new Set(cluster.entryIds).size,
280
+ throwTimeBucket: modeOfMap(bucketCounts[i]),
281
+ throwerPlaceName: modeOfMap(placeCounts[i]),
282
+ effectCallout,
283
+ effectCalloutConfidence: effectStats && effectStats.confidenceCount > 0
284
+ ? Math.round((effectStats.confidenceSum / effectStats.confidenceCount) * 1000) / 1000
285
+ : null,
286
+ effectCalloutSamples: effectStats && effectStats.samples > 0 ? effectStats.samples : null,
287
+ side: modeOfMap(sideCounts[i]) as "t" | "ct" | null,
288
+ };
289
+ })
290
+ .sort((a, b) => b.count - a.count || a.grenade.localeCompare(b.grenade));
291
+ }
292
+
293
+ function distance(a: Vec3, b: Vec3): number {
294
+ return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
295
+ }
296
+
297
+ function grenadeKind(grenade: string): GrenadeClusterKind {
298
+ const value = grenade.toLowerCase();
299
+ if (value.includes("smoke")) return "smoke";
300
+ if (value.includes("molotov")) return "molotov";
301
+ if (value.includes("incendiary") || value.includes("incgrenade")) return "incendiary";
302
+ if (value.includes("flash")) return "flashbang";
303
+ return "he";
304
+ }
305
+
306
+ function landingRadiusFor(mode: LineupClusterMode, grenade: string): number {
307
+ return GRENADE_CLUSTER_PRESETS[mode].landingRadius[grenadeKind(grenade)];
308
+ }
309
+
310
+ function originRadiusFor(mode: LineupClusterMode, grenade: string): number | null {
311
+ const radius = GRENADE_CLUSTER_PRESETS[mode].originRadius;
312
+ return radius ? radius[grenadeKind(grenade)] : null;
313
+ }
314
+
315
+ function averagePoint(current: Vec3, next: Vec3, count: number): Vec3 {
316
+ if (count <= 1) return next;
317
+ return {
318
+ x: current.x + (next.x - current.x) / count,
319
+ y: current.y + (next.y - current.y) / count,
320
+ z: current.z + (next.z - current.z) / count,
321
+ };
322
+ }
323
+
324
+ /** 取 Map<string, number> 中 value 最大的 key;空 map 返回 null。 */
325
+ function modeOfMap(map: Map<string, number>): string | null {
326
+ if (map.size === 0) return null;
327
+ let bestKey: string | null = null;
328
+ let bestCount = 0;
329
+ for (const [key, count] of map) {
330
+ if (count > bestCount) {
331
+ bestCount = count;
332
+ bestKey = key;
333
+ }
334
+ }
335
+ return bestKey;
336
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveCompactNav, findNavPath, nearestNavArea, type AwpyNav } from "./nav.js";
3
+
4
+ const source: AwpyNav = {
5
+ version: 35,
6
+ sub_version: 1,
7
+ is_analyzed: true,
8
+ areas: {
9
+ 1: {
10
+ area_id: 1,
11
+ hull_index: 0,
12
+ dynamic_attribute_flags: 0,
13
+ corners: [
14
+ { x: 0, y: 0, z: 0 },
15
+ { x: 10, y: 0, z: 0 },
16
+ { x: 10, y: 10, z: 0 },
17
+ { x: 0, y: 10, z: 0 },
18
+ ],
19
+ connections: [2],
20
+ ladders_above: [],
21
+ ladders_below: [],
22
+ },
23
+ 2: {
24
+ area_id: 2,
25
+ hull_index: 0,
26
+ dynamic_attribute_flags: 0,
27
+ corners: [
28
+ { x: 10, y: 0, z: 0 },
29
+ { x: 20, y: 0, z: 0 },
30
+ { x: 20, y: 10, z: 0 },
31
+ { x: 10, y: 10, z: 0 },
32
+ ],
33
+ connections: [3],
34
+ ladders_above: [],
35
+ ladders_below: [],
36
+ },
37
+ 3: {
38
+ area_id: 3,
39
+ hull_index: 0,
40
+ dynamic_attribute_flags: 0,
41
+ corners: [
42
+ { x: 20, y: 0, z: 0 },
43
+ { x: 30, y: 0, z: 0 },
44
+ { x: 30, y: 10, z: 0 },
45
+ { x: 20, y: 10, z: 0 },
46
+ ],
47
+ connections: [],
48
+ ladders_above: [],
49
+ ladders_below: [],
50
+ },
51
+ },
52
+ };
53
+
54
+ describe("nav graph", () => {
55
+ it("derives compact areas and follows directed adjacency", () => {
56
+ const nav = deriveCompactNav("de_test", 123, source);
57
+ expect(nav.areas).toHaveLength(3);
58
+ expect(findNavPath(nav, 1, 3)).toEqual([1, 2, 3]);
59
+ expect(findNavPath(nav, 3, 1)).toEqual([]);
60
+ });
61
+
62
+ it("maps a world point to its containing or nearest nav area", () => {
63
+ const nav = deriveCompactNav("de_test", 123, source);
64
+ expect(nearestNavArea(nav, { x: 4, y: 4, z: 2 })?.id).toBe(1);
65
+ expect(nearestNavArea(nav, { x: 18, y: 5, z: 2 })?.id).toBe(2);
66
+ expect(nearestNavArea(nav, { x: 34, y: 5, z: 2 })?.id).toBe(3);
67
+ });
68
+ });
package/src/nav.ts ADDED
@@ -0,0 +1,228 @@
1
+ export interface Vec3 {
2
+ x: number;
3
+ y: number;
4
+ z: number;
5
+ }
6
+
7
+ export interface AwpyNavArea {
8
+ area_id: number;
9
+ hull_index: number;
10
+ dynamic_attribute_flags: number;
11
+ corners: Vec3[];
12
+ connections: number[];
13
+ ladders_above: number[];
14
+ ladders_below: number[];
15
+ }
16
+
17
+ export interface AwpyNav {
18
+ version: number;
19
+ sub_version: number;
20
+ is_analyzed: boolean;
21
+ areas: Record<string | number, AwpyNavArea>;
22
+ }
23
+
24
+ export interface CompactNavArea {
25
+ id: number;
26
+ corners: Vec3[];
27
+ centroid: Vec3;
28
+ neighbors: number[];
29
+ }
30
+
31
+ export interface CompactNav {
32
+ mapName: string;
33
+ buildId: number;
34
+ sourceFormat: {
35
+ version: number;
36
+ subVersion: number;
37
+ };
38
+ areas: CompactNavArea[];
39
+ }
40
+
41
+ export function deriveCompactNav(mapName: string, buildId: number, source: AwpyNav): CompactNav {
42
+ const sourceAreas = Object.values(source.areas);
43
+ const knownIds = new Set(sourceAreas.map((area) => area.area_id));
44
+
45
+ return {
46
+ mapName,
47
+ buildId,
48
+ sourceFormat: {
49
+ version: source.version,
50
+ subVersion: source.sub_version,
51
+ },
52
+ areas: sourceAreas.map((area) => ({
53
+ id: area.area_id,
54
+ corners: area.corners,
55
+ centroid: centroid(area.corners),
56
+ neighbors: [...new Set(area.connections)].filter((id) => knownIds.has(id)),
57
+ })),
58
+ };
59
+ }
60
+
61
+ export function findNavPath(nav: CompactNav, startId: number, endId: number): number[] {
62
+ if (startId === endId) return nav.areas.some((area) => area.id === startId) ? [startId] : [];
63
+
64
+ const byId = new Map(nav.areas.map((area) => [area.id, area]));
65
+ if (!byId.has(startId) || !byId.has(endId)) return [];
66
+
67
+ const queue = [startId];
68
+ const previous = new Map<number, number | null>([[startId, null]]);
69
+
70
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
71
+ const current = queue[cursor]!;
72
+ for (const neighbor of byId.get(current)!.neighbors) {
73
+ if (previous.has(neighbor)) continue;
74
+ previous.set(neighbor, current);
75
+ if (neighbor === endId) return reconstructPath(previous, endId);
76
+ queue.push(neighbor);
77
+ }
78
+ }
79
+
80
+ return [];
81
+ }
82
+
83
+ export function nearestNavArea(nav: CompactNav, point: Vec3): CompactNavArea | undefined {
84
+ let bestContaining: CompactNavArea | undefined;
85
+ let bestContainingZ = Number.POSITIVE_INFINITY;
86
+
87
+ for (const area of nav.areas) {
88
+ if (!pointInPolygon(point, area.corners)) continue;
89
+ const zDistance = Math.abs(point.z - area.centroid.z);
90
+ if (zDistance < bestContainingZ) {
91
+ bestContaining = area;
92
+ bestContainingZ = zDistance;
93
+ }
94
+ }
95
+ if (bestContaining) return bestContaining;
96
+
97
+ let nearest: CompactNavArea | undefined;
98
+ let nearestDistance = Number.POSITIVE_INFINITY;
99
+ for (const area of nav.areas) {
100
+ const distance = squaredDistance(point, area.centroid);
101
+ if (distance < nearestDistance) {
102
+ nearest = area;
103
+ nearestDistance = distance;
104
+ }
105
+ }
106
+ return nearest;
107
+ }
108
+
109
+ export interface NavZCluster {
110
+ min: number;
111
+ max: number;
112
+ median: number;
113
+ count: number;
114
+ }
115
+
116
+ export interface NavZSample {
117
+ /** 落在 polygon 内的 nav area Z 质心列表(已排序)。 */
118
+ zValues: number[];
119
+ min: number;
120
+ max: number;
121
+ median: number;
122
+ /**
123
+ * 按 Z 间距自动分组的簇(默认间距阈值 50 units)。
124
+ * 相邻簇之间的间隙就是 zMin/zMax 边界的自然分割点。
125
+ * 单层区域通常只有 1 个簇;Hut/HutRoof 这类重叠区域会出现 2 个簇。
126
+ */
127
+ clusters: NavZCluster[];
128
+ }
129
+
130
+ /**
131
+ * 采样 XY polygon 内所有 nav area 的 Z 质心,返回分布统计。
132
+ *
133
+ * polygon 使用世界坐标 XY(与 MapZone.polygon 同系)。
134
+ * 返回 null 表示 polygon 内无 nav area 覆盖(坐标系不匹配或地图未标定)。
135
+ *
136
+ * 用途:标定重叠 callout 的 zMin/zMax 时,查看 clusters 找到簇间间隙,
137
+ * 把间隙中点作为边界值填入 MapZone。不要用 median 直接作为 zMin/zMax。
138
+ */
139
+ export function sampleNavZ(
140
+ nav: CompactNav,
141
+ polygon: Array<[number, number]>,
142
+ clusterGapThreshold = 50,
143
+ zFilter?: { zMin?: number; zMax?: number },
144
+ ): NavZSample | null {
145
+ const zValues: number[] = [];
146
+ for (const area of nav.areas) {
147
+ const { x, y, z } = area.centroid;
148
+ if (zFilter?.zMin !== undefined && z < zFilter.zMin) continue;
149
+ if (zFilter?.zMax !== undefined && z > zFilter.zMax) continue;
150
+ if (pointInPolygon2d(x, y, polygon)) zValues.push(z);
151
+ }
152
+ if (zValues.length === 0) return null;
153
+ zValues.sort((a, b) => a - b);
154
+
155
+ const min = zValues[0]!;
156
+ const max = zValues[zValues.length - 1]!;
157
+ const median = zValues[Math.floor(zValues.length / 2)]!;
158
+ return { zValues, min, max, median, clusters: detectClusters(zValues, clusterGapThreshold) };
159
+ }
160
+
161
+ function pointInPolygon2d(x: number, y: number, polygon: Array<[number, number]>): boolean {
162
+ let inside = false;
163
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
164
+ const [xi, yi] = polygon[i]!;
165
+ const [xj, yj] = polygon[j]!;
166
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
167
+ }
168
+ return inside;
169
+ }
170
+
171
+ function detectClusters(sortedZ: number[], gapThreshold: number): NavZCluster[] {
172
+ if (sortedZ.length === 0) return [];
173
+ const clusters: NavZCluster[] = [];
174
+ let start = 0;
175
+ for (let i = 1; i <= sortedZ.length; i += 1) {
176
+ if (i < sortedZ.length && sortedZ[i]! - sortedZ[i - 1]! <= gapThreshold) continue;
177
+ const slice = sortedZ.slice(start, i);
178
+ clusters.push({
179
+ min: slice[0]!,
180
+ max: slice[slice.length - 1]!,
181
+ median: slice[Math.floor(slice.length / 2)]!,
182
+ count: slice.length,
183
+ });
184
+ start = i;
185
+ }
186
+ return clusters;
187
+ }
188
+
189
+ function centroid(points: Vec3[]): Vec3 {
190
+ if (points.length === 0) return { x: 0, y: 0, z: 0 };
191
+ const total = points.reduce(
192
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y, z: sum.z + point.z }),
193
+ { x: 0, y: 0, z: 0 },
194
+ );
195
+ return {
196
+ x: total.x / points.length,
197
+ y: total.y / points.length,
198
+ z: total.z / points.length,
199
+ };
200
+ }
201
+
202
+ function pointInPolygon(point: Vec3, polygon: Vec3[]): boolean {
203
+ let inside = false;
204
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
205
+ const current = polygon[i]!;
206
+ const previous = polygon[j]!;
207
+ const crosses =
208
+ current.y > point.y !== previous.y > point.y &&
209
+ point.x <
210
+ ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
211
+ if (crosses) inside = !inside;
212
+ }
213
+ return inside;
214
+ }
215
+
216
+ function squaredDistance(a: Vec3, b: Vec3): number {
217
+ return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2;
218
+ }
219
+
220
+ function reconstructPath(previous: Map<number, number | null>, endId: number): number[] {
221
+ const path: number[] = [];
222
+ let current: number | null = endId;
223
+ while (current !== null) {
224
+ path.push(current);
225
+ current = previous.get(current) ?? null;
226
+ }
227
+ return path.reverse();
228
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateMapPositionCandidates, materializeReviewedPositionAsset } from "./position-candidates.js";
3
+
4
+ const rows = [1, 2].map((roundNumber) => ({ matchId: "m1", mapName: "de_inferno", roundNumber, teamKey: "teamA", side: "t" as const, playerIndex: 0, steamId64: "p1", openingEligibleSeconds: 20, openingPositionGroupDwell: [{ positionGroupId: "banana", seconds: 16, share: 0.8 }], openingPath: [{ tick: 100, callout: "Banana", positionGroupId: "banana", x: 1, y: 2, z: 3 }] }));
5
+
6
+ describe("map position candidates", () => {
7
+ it("generates deterministic candidates and materializes reviewed assets with provenance", () => {
8
+ const first = generateMapPositionCandidates(rows); const second = generateMapPositionCandidates([...rows].reverse());
9
+ expect(first).toEqual(second);
10
+ expect(first[0]).toMatchObject({ proposedId: "banana", proposedDisplayName: "香蕉道", sampleCount: 2 });
11
+ const asset = materializeReviewedPositionAsset(first, [{ candidateId: first[0]!.id, action: "rename", displayName: "香蕉道默认" }], { mapName: "de_inferno", side: "t", reviewedAt: "2026-07-14T00:00:00.000Z", reviewer: "test" });
12
+ expect(asset.groups[0]).toMatchObject({ id: "banana", name: "香蕉道默认", callouts: ["Banana"] });
13
+ expect(asset.provenance.decisions).toHaveLength(1);
14
+ });
15
+
16
+ it("keeps unresolved candidates unresolved without guessing a name", () => {
17
+ const candidates = generateMapPositionCandidates([{ ...rows[0]!, openingPositionGroupDwell: [], openingPath: [] }]);
18
+ expect(candidates[0]).toMatchObject({ type: "unresolved", proposedDisplayName: "未命名候选" });
19
+ expect(materializeReviewedPositionAsset(candidates, [], { mapName: "de_inferno", side: "t", reviewedAt: "2026-07-14T00:00:00.000Z", reviewer: "test" }).unresolvedCandidateIds).toEqual([candidates[0]!.id]);
20
+ });
21
+
22
+ it("separates deterministic unknown spatial clusters and classifies transit paths without naming them", () => {
23
+ const unknown = [
24
+ { ...rows[0]!, roundNumber: 1, openingPositionGroupDwell: [], openingPath: [{ tick: 100, callout: null, positionGroupId: null, x: 0, y: 0, z: 0 }, { tick: 200, callout: null, positionGroupId: null, x: 50, y: 40, z: 0 }] },
25
+ { ...rows[0]!, roundNumber: 2, openingPositionGroupDwell: [], openingPath: [{ tick: 100, callout: null, positionGroupId: null, x: 10, y: 10, z: 0 }, { tick: 200, callout: null, positionGroupId: null, x: 60, y: 50, z: 0 }] },
26
+ { ...rows[0]!, roundNumber: 3, openingPositionGroupDwell: [], openingPath: [{ tick: 100, callout: null, positionGroupId: null, x: 1600, y: 0, z: 0 }, { tick: 200, callout: null, positionGroupId: null, x: 1660, y: 20, z: 0 }] },
27
+ { ...rows[0]!, roundNumber: 4, openingPositionGroupDwell: [], openingPath: [{ tick: 100, callout: "A", positionGroupId: null, x: 0, y: 0, z: 0 }, { tick: 200, callout: "B", positionGroupId: null, x: 1200, y: 0, z: 0 }] },
28
+ ];
29
+ const candidates = generateMapPositionCandidates(unknown);
30
+ expect(candidates.filter((candidate) => candidate.type === "stable_spatial_position")).toHaveLength(1);
31
+ expect(candidates.some((candidate) => candidate.type === "transit")).toBe(true);
32
+ expect(new Set(candidates.map((candidate) => candidate.proposedId)).size).toBe(candidates.length);
33
+ expect(candidates.every((candidate) => candidate.proposedDisplayName === "未命名候选")).toBe(true);
34
+ });
35
+
36
+ it("keeps semantic candidate ids stable when unrelated candidates are added", () => {
37
+ const base = generateMapPositionCandidates(rows);
38
+ const extended = generateMapPositionCandidates([...rows, { ...rows[0]!, playerIndex: 9, steamId64: "p9", openingPositionGroupDwell: [{ positionGroupId: "alt", seconds: 16, share: 0.8 }], openingPath: [{ tick: 100, callout: "Alt", positionGroupId: "alt", x: 900, y: 2, z: 3 }] }]);
39
+ expect(extended.find((candidate) => candidate.proposedId === "banana")?.id).toBe(base.find((candidate) => candidate.proposedId === "banana")?.id);
40
+ });
41
+
42
+ it("materializes a reviewer-specified split as independently reviewable children", () => {
43
+ const input = [
44
+ { ...rows[0]!, roundNumber: 1 },
45
+ { ...rows[0]!, roundNumber: 2 },
46
+ { ...rows[0]!, roundNumber: 3 },
47
+ ];
48
+ const candidate = generateMapPositionCandidates(input)[0]!;
49
+ const groups = candidate.representativeEvidence.map((evidence, index) => ({ id: index === 0 ? "left" : "right", evidence: [evidence] }));
50
+ const asset = materializeReviewedPositionAsset([candidate], [{ candidateId: candidate.id, action: "split", splitGroups: [groups[0]!, { id: "right", evidence: groups.slice(1).flatMap((group) => group.evidence) }] }], { mapName: "de_inferno", side: "t", reviewedAt: "2026-07-14T00:00:00.000Z", reviewer: "test" });
51
+ expect(asset.splitCandidates).toHaveLength(2);
52
+ expect(asset.splitCandidates.every((child) => child.parentCandidateId === candidate.id)).toBe(true);
53
+ expect(asset.unresolvedCandidateIds).toEqual(asset.splitCandidates.map((child) => child.id).sort());
54
+ expect(() => materializeReviewedPositionAsset([candidate], [{ candidateId: candidate.id, action: "split" }], { mapName: "de_inferno", side: "t", reviewedAt: "2026-07-14T00:00:00.000Z", reviewer: "test" })).toThrow("至少需要两个");
55
+ });
56
+
57
+ it("surfaces legacy review ids that no longer match stable candidates", () => {
58
+ const candidate = generateMapPositionCandidates(rows)[0]!;
59
+ const asset = materializeReviewedPositionAsset([candidate], [{ candidateId: "de_inferno:t:001", action: "rename", displayName: "旧编号" }], { mapName: "de_inferno", side: "t", reviewedAt: "2026-07-14T00:00:00.000Z", reviewer: "test" });
60
+ expect(asset.unmatchedDecisionIds).toEqual(["de_inferno:t:001"]);
61
+ });
62
+ });