@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,183 @@
1
+ import { positionGroupDisplay } from "./default-positions.js";
2
+
3
+ export const MAP_POSITION_CANDIDATE_VERSION = 3;
4
+ export type PositionCandidateType = "stable_spatial_position" | "responsibility_group" | "opening_action" | "transit" | "unresolved" | "unclassifiable";
5
+ export type PositionCandidateReviewAction = "keep" | "rename" | "merge" | "split" | "opening_action" | "transit" | "unclassifiable" | "unresolved";
6
+
7
+ export interface OpeningPositionCandidateInput {
8
+ matchId: string; mapName: string; roundNumber: number; teamKey: string; side: "t" | "ct"; playerIndex: number; steamId64: string;
9
+ openingEligibleSeconds: number | null;
10
+ openingMeanComponentSize?: number | null;
11
+ openingIsolationSeconds?: number | null;
12
+ openingPositionGroupDwell: Array<{ positionGroupId: string; seconds: number; share: number }>;
13
+ openingPath: Array<{ tick: number; callout: string | null; positionGroupId: string | null; x: number; y: number; z: number }>;
14
+ }
15
+
16
+ export interface MapPositionCandidate {
17
+ version: typeof MAP_POSITION_CANDIDATE_VERSION; id: string; parentCandidateId?: string; mapName: string; side: "t" | "ct"; type: PositionCandidateType;
18
+ proposedId: string; proposedDisplayName: string; callouts: string[];
19
+ trajectorySummary: { start: { x: number; y: number; z: number } | null; end: { x: number; y: number; z: number } | null; dwellSeconds: number; pathSamples: number };
20
+ sampleCount: number; teamCount: number; playerCount: number; assignmentStability: number | null; overlap: number | null;
21
+ teamKeys: string[];
22
+ componentSummary: { meanOpeningComponentSize: number | null; isolatedShare: number | null };
23
+ representativeEvidence: Array<{ matchId: string; roundNumber: number; playerIndex: number }>;
24
+ confidence: number; limitations: string[];
25
+ }
26
+
27
+ function rounded(value: number): number { return Number(value.toFixed(3)); }
28
+
29
+ function stableHash(value: string): string {
30
+ let hash = 2166136261;
31
+ for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); }
32
+ return (hash >>> 0).toString(36);
33
+ }
34
+
35
+ function evidenceKey(value: { matchId: string; roundNumber: number; playerIndex: number }): string {
36
+ return `${value.matchId}:${value.roundNumber}:${value.playerIndex}`;
37
+ }
38
+
39
+ function distance(first: { x: number; y: number; z: number }, second: { x: number; y: number; z: number }): number {
40
+ return Math.hypot(first.x - second.x, first.y - second.y, first.z - second.z);
41
+ }
42
+
43
+ function pathClass(row: OpeningPositionCandidateInput): "stationary" | "opening_action" | "transit" | "no_path" {
44
+ const start = row.openingPath[0];
45
+ const end = row.openingPath.at(-1);
46
+ if (!start || !end) return "no_path";
47
+ const displacement = distance(start, end);
48
+ if (displacement >= 900) return "transit";
49
+ if (displacement >= 400 && new Set(row.openingPath.map((point) => point.callout).filter(Boolean)).size >= 2) return "opening_action";
50
+ return "stationary";
51
+ }
52
+
53
+ function unresolvedSpatialKey(row: OpeningPositionCandidateInput): string {
54
+ const point = row.openingPath.at(-1) ?? row.openingPath[0];
55
+ if (!point) return "no_path";
56
+ return [pathClass(row), Math.round(point.x / 384), Math.round(point.y / 384), Math.round(point.z / 192)].join(":");
57
+ }
58
+
59
+ function meanPoint(points: Array<{ x: number; y: number; z: number }>): { x: number; y: number; z: number } | null {
60
+ if (points.length === 0) return null;
61
+ return {
62
+ x: rounded(points.reduce((sum, point) => sum + point.x, 0) / points.length),
63
+ y: rounded(points.reduce((sum, point) => sum + point.y, 0) / points.length),
64
+ z: rounded(points.reduce((sum, point) => sum + point.z, 0) / points.length),
65
+ };
66
+ }
67
+
68
+ export function generateMapPositionCandidates(rows: OpeningPositionCandidateInput[]): MapPositionCandidate[] {
69
+ const groups = new Map<string, Array<{ row: OpeningPositionCandidateInput; dwell: OpeningPositionCandidateInput["openingPositionGroupDwell"][number] | null }>>();
70
+ for (const row of rows) {
71
+ const dwell = row.openingPositionGroupDwell.length ? [...row.openingPositionGroupDwell].sort((a, b) => b.seconds - a.seconds || a.positionGroupId.localeCompare(b.positionGroupId))[0]! : null;
72
+ const groupId = dwell?.positionGroupId ?? `unresolved:${unresolvedSpatialKey(row)}`;
73
+ const key = `${row.mapName}\t${row.side}\t${groupId}`;
74
+ groups.set(key, [...(groups.get(key) ?? []), { row, dwell }]);
75
+ }
76
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, items]) => {
77
+ items.sort((a, b) => a.row.matchId.localeCompare(b.row.matchId) || a.row.roundNumber - b.row.roundNumber || a.row.playerIndex - b.row.playerIndex);
78
+ const [mapName, side, groupId] = key.split("\t") as [string, "t" | "ct", string];
79
+ const unresolved = groupId.startsWith("unresolved:");
80
+ const display = unresolved ? { resolved: false, displayName: "未命名候选", officialName: null } : positionGroupDisplay(mapName, side, groupId);
81
+ const paths = items.flatMap((item) => item.row.openingPath);
82
+ const shares = items.map((item) => item.dwell?.share ?? 0);
83
+ const endPoints = items.map((item) => item.row.openingPath.at(-1)).filter((point): point is NonNullable<typeof point> => point != null);
84
+ const center = meanPoint(endPoints);
85
+ const spatialStability = center && endPoints.length ? rounded(Math.max(0, 1 - endPoints.reduce((sum, point) => sum + distance(point, center), 0) / endPoints.length / 600)) : null;
86
+ const assignmentStability = unresolved ? spatialStability : shares.length ? rounded(shares.reduce((sum, value) => sum + value, 0) / shares.length) : null;
87
+ const roundAssignments = new Map<string, number>();
88
+ for (const item of items) {
89
+ const roundKey = `${item.row.matchId}:${item.row.roundNumber}:${item.row.teamKey}`;
90
+ roundAssignments.set(roundKey, (roundAssignments.get(roundKey) ?? 0) + 1);
91
+ }
92
+ const overlap = roundAssignments.size ? rounded([...roundAssignments.values()].filter((count) => count > 1).length / roundAssignments.size) : null;
93
+ const motion = pathClass(items[0]!.row);
94
+ const type: PositionCandidateType = unresolved
95
+ ? motion === "transit" ? "transit" : motion === "opening_action" ? "opening_action" : (assignmentStability ?? 0) >= 0.55 && items.length >= 2 ? "stable_spatial_position" : "unresolved"
96
+ : (assignmentStability ?? 0) >= 0.55 ? "stable_spatial_position" : "responsibility_group";
97
+ const starts = items.map((item) => item.row.openingPath[0]).filter((point): point is NonNullable<typeof point> => point != null);
98
+ const openingSeconds = items.reduce((sum, item) => sum + (item.row.openingEligibleSeconds ?? 0), 0);
99
+ const isolatedSeconds = items.reduce((sum, item) => sum + (item.row.openingIsolationSeconds ?? 0), 0);
100
+ const componentValues = items.map((item) => item.row.openingMeanComponentSize).filter((value): value is number => value != null);
101
+ const semanticKey = [MAP_POSITION_CANDIDATE_VERSION, mapName, side, type, groupId, motion, ...items.map((item) => `${evidenceKey(item.row)}:${Math.round((item.row.openingPath.at(-1)?.x ?? 0) / 64)}:${Math.round((item.row.openingPath.at(-1)?.y ?? 0) / 64)}:${Math.round((item.row.openingPath.at(-1)?.z ?? 0) / 32)}`)].join("|");
102
+ const stableId = `${mapName}:${side}:${type}:${stableHash(semanticKey)}`;
103
+ return {
104
+ version: MAP_POSITION_CANDIDATE_VERSION, id: stableId, mapName, side, type,
105
+ proposedId: unresolved ? `${side}_candidate_${stableHash(semanticKey)}` : groupId,
106
+ proposedDisplayName: display.resolved ? display.displayName : "未命名候选",
107
+ callouts: [...new Set(paths.map((point) => point.callout).filter((value): value is string => value != null))].sort(),
108
+ trajectorySummary: { start: meanPoint(starts), end: meanPoint(endPoints), dwellSeconds: rounded(items.reduce((sum, item) => sum + (item.dwell?.seconds ?? (motion === "stationary" ? item.row.openingEligibleSeconds ?? 0 : 0)), 0)), pathSamples: paths.length },
109
+ sampleCount: new Set(items.map((item) => `${item.row.matchId}:${item.row.roundNumber}`)).size,
110
+ teamCount: new Set(items.map((item) => `${item.row.matchId}:${item.row.teamKey}`)).size,
111
+ playerCount: new Set(items.map((item) => item.row.steamId64)).size,
112
+ teamKeys: [...new Set(items.map((item) => item.row.teamKey))].sort(),
113
+ componentSummary: { meanOpeningComponentSize: componentValues.length ? rounded(componentValues.reduce((sum, value) => sum + value, 0) / componentValues.length) : null, isolatedShare: openingSeconds > 0 ? rounded(isolatedSeconds / openingSeconds) : null },
114
+ assignmentStability, overlap,
115
+ representativeEvidence: items.slice(0, 5).map((item) => ({ matchId: item.row.matchId, roundNumber: item.row.roundNumber, playerIndex: item.row.playerIndex })),
116
+ confidence: rounded(Math.min(1, items.length / 20) * (assignmentStability ?? 0.25) * (1 - (overlap ?? 0))),
117
+ limitations: [...(paths.length === 0 ? ["缺少 opening path/coordinates。"] : []), ...(unresolved ? ["无法从当前地图资产解析 position group;候选名称保持未命名,需审阅后才能写入资产。"] : [])],
118
+ };
119
+ });
120
+ }
121
+
122
+ export interface PositionCandidateSplitGroup { id: string; evidence: Array<{ matchId: string; roundNumber: number; playerIndex: number }>; displayName?: string }
123
+ export interface PositionCandidateReview { candidateId: string; action: PositionCandidateReviewAction; displayName?: string; targetId?: string; note?: string; splitGroups?: PositionCandidateSplitGroup[] }
124
+ export interface ReviewedPositionAsset { version: 3; mapName: string; side: "t" | "ct"; groups: Array<{ id: string; name: string; callouts: string[] }>; unresolvedCandidateIds: string[]; splitCandidates: MapPositionCandidate[]; unmatchedDecisionIds: string[]; provenance: { candidateVersion: number; reviewedAt: string; reviewer: string; decisions: PositionCandidateReview[] } }
125
+
126
+ function splitChildren(candidate: MapPositionCandidate, decision: PositionCandidateReview): MapPositionCandidate[] {
127
+ const groups = decision.splitGroups;
128
+ if (!groups || groups.length < 2) throw new Error(`候选 ${candidate.id} 的 split 至少需要两个具名 evidence 分组。`);
129
+ const parentEvidence = new Set(candidate.representativeEvidence.map(evidenceKey));
130
+ const seen = new Set<string>();
131
+ for (const group of groups) {
132
+ if (!group.id.trim() || group.evidence.length === 0) throw new Error(`候选 ${candidate.id} 的 split 分组必须提供名称和 evidence。`);
133
+ for (const evidence of group.evidence) {
134
+ const key = evidenceKey(evidence);
135
+ if (!parentEvidence.has(key) || seen.has(key)) throw new Error(`候选 ${candidate.id} 的 split evidence 必须来自父候选且不可重复。`);
136
+ seen.add(key);
137
+ }
138
+ }
139
+ if (seen.size !== parentEvidence.size) throw new Error(`候选 ${candidate.id} 的 split 必须明确分配全部代表 evidence。`);
140
+ return groups.map((group) => {
141
+ const members = [...group.evidence].sort((a, b) => evidenceKey(a).localeCompare(evidenceKey(b)));
142
+ const childId = `${candidate.id}:split:${stableHash([candidate.id, group.id, ...members.map(evidenceKey)].join("|"))}`;
143
+ return {
144
+ ...candidate,
145
+ id: childId,
146
+ parentCandidateId: candidate.id,
147
+ proposedId: `${candidate.proposedId}_${group.id}`,
148
+ proposedDisplayName: group.displayName?.trim() || "未命名候选",
149
+ representativeEvidence: members,
150
+ sampleCount: new Set(members.map((item) => `${item.matchId}:${item.roundNumber}`)).size,
151
+ playerCount: new Set(members.map((item) => item.playerIndex)).size,
152
+ confidence: rounded(Math.min(candidate.confidence, members.length / Math.max(candidate.representativeEvidence.length, 1))),
153
+ limitations: [...candidate.limitations, "由人工 split 审阅产生;名称和归类仍需独立确认。"],
154
+ };
155
+ });
156
+ }
157
+
158
+ export function materializeReviewedPositionAsset(candidates: MapPositionCandidate[], decisions: PositionCandidateReview[], input: { mapName: string; side: "t" | "ct"; reviewedAt: string; reviewer: string }): ReviewedPositionAsset {
159
+ const decisionById = new Map(decisions.map((decision) => [decision.candidateId, decision]));
160
+ const groups = new Map<string, { id: string; name: string; callouts: Set<string> }>();
161
+ const unresolvedCandidateIds: string[] = [];
162
+ const splitCandidates: MapPositionCandidate[] = [];
163
+ const matchedDecisionIds = new Set<string>();
164
+ for (const candidate of candidates.filter((row) => row.mapName === input.mapName && row.side === input.side)) {
165
+ const decision = decisionById.get(candidate.id) ?? { candidateId: candidate.id, action: "unresolved" as const };
166
+ if (decisionById.has(candidate.id)) matchedDecisionIds.add(candidate.id);
167
+ if (decision.action === "split") {
168
+ const children = splitChildren(candidate, decision);
169
+ splitCandidates.push(...children);
170
+ unresolvedCandidateIds.push(...children.map((child) => child.id));
171
+ continue;
172
+ }
173
+ if (["unresolved", "unclassifiable", "opening_action", "transit"].includes(decision.action)) { unresolvedCandidateIds.push(candidate.id); continue; }
174
+ const id = decision.action === "merge" ? decision.targetId : candidate.proposedId;
175
+ if (!id) { unresolvedCandidateIds.push(candidate.id); continue; }
176
+ const current = groups.get(id) ?? { id, name: decision.displayName ?? candidate.proposedDisplayName, callouts: new Set<string>() };
177
+ if (decision.action === "rename" && decision.displayName) current.name = decision.displayName;
178
+ for (const callout of candidate.callouts) current.callouts.add(callout);
179
+ groups.set(id, current);
180
+ }
181
+ const unmatchedDecisionIds = decisions.filter((decision) => !matchedDecisionIds.has(decision.candidateId)).map((decision) => decision.candidateId).sort();
182
+ return { version: 3, mapName: input.mapName, side: input.side, groups: [...groups.values()].map((group) => ({ ...group, callouts: [...group.callouts].sort() })).sort((a, b) => a.id.localeCompare(b.id)), unresolvedCandidateIds: unresolvedCandidateIds.sort(), splitCandidates: splitCandidates.sort((a, b) => a.id.localeCompare(b.id)), unmatchedDecisionIds, provenance: { candidateVersion: MAP_POSITION_CANDIDATE_VERSION, reviewedAt: input.reviewedAt, reviewer: input.reviewer, decisions: [...decisions].sort((a, b) => a.candidateId.localeCompare(b.candidateId)) } };
183
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 雷达场栅格 —— 由地图 nav 决定的确定性规则栅格(不由 demo 决定)。
3
+ *
4
+ * 同一地图任意 demo 算出同一套格、同序、同 index,所以两场场可逐元素相加/相减。
5
+ * 格 = 把 nav area 质心按 cellSize 桶进规则格,只保留含 ≥1 质心的「可行走格」(稀疏)。
6
+ * 每格代表点取桶内质心均值;z 不加靶高(靶高由 core 计算 LOS 时统一加,避免双份常量漂移)。
7
+ */
8
+ import type { Vec3 } from "./nav.js";
9
+ import { getMapNav } from "./geometry-assets.js";
10
+
11
+ /** 默认格边长(世界单位)。callout-grid 是 10u 更细;覆盖场用 128u 控制格数 ~300。 */
12
+ export const RADAR_FIELD_CELL_SIZE = 128;
13
+
14
+ /** world→radar 标定版本;MAP_CALIBRATIONS 变动时手动 +1,使旧缓存场失效。 */
15
+ export const MAP_CALIBRATION_VERSION = "1";
16
+
17
+ export interface RadarFieldGridIndex {
18
+ cellSize: number;
19
+ /** 每格代表世界坐标 [x, y, z],与 fields 列同序。 */
20
+ cells: Array<[number, number, number]>;
21
+ /** "gx,gy" → 列 index;compute 用玩家落点查所属格。 */
22
+ keyToIndex: Map<string, number>;
23
+ }
24
+
25
+ function cellKey(x: number, y: number, cellSize: number): string {
26
+ return `${Math.floor(x / cellSize)},${Math.floor(y / cellSize)}`;
27
+ }
28
+
29
+ /**
30
+ * 构建某图的雷达场栅格;无 nav 资产时返回 null(调用方降级)。
31
+ * 结果对同一 (mapName, cellSize) 完全确定,可安全用于跨场合并。
32
+ */
33
+ export function buildRadarFieldGrid(
34
+ mapName: string,
35
+ cellSize: number = RADAR_FIELD_CELL_SIZE
36
+ ): RadarFieldGridIndex | null {
37
+ const nav = getMapNav(mapName);
38
+ if (!nav || nav.areas.length === 0) return null;
39
+
40
+ const acc = new Map<string, { x: number; y: number; z: number; n: number }>();
41
+ for (const area of nav.areas) {
42
+ const c: Vec3 = area.centroid;
43
+ const key = cellKey(c.x, c.y, cellSize);
44
+ const e = acc.get(key) ?? { x: 0, y: 0, z: 0, n: 0 };
45
+ e.x += c.x;
46
+ e.y += c.y;
47
+ e.z += c.z;
48
+ e.n += 1;
49
+ acc.set(key, e);
50
+ }
51
+
52
+ // 按 key 排序保证列序确定(跨场、跨进程一致)。
53
+ const sorted = [...acc.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1));
54
+ const cells: Array<[number, number, number]> = sorted.map(([, e]) => [
55
+ e.x / e.n,
56
+ e.y / e.n,
57
+ e.z / e.n,
58
+ ]);
59
+ const keyToIndex = new Map(sorted.map(([key], i) => [key, i]));
60
+ return { cellSize, cells, keyToIndex };
61
+ }
62
+
63
+ /** 玩家世界坐标 → 所属格列 index;不在任何可行走格内时 -1。 */
64
+ export function radarFieldCellAt(grid: RadarFieldGridIndex, x: number, y: number): number {
65
+ return grid.keyToIndex.get(cellKey(x, y, grid.cellSize)) ?? -1;
66
+ }
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { CALLOUT_DICT } from "./callout-names.js";
5
+ import { getMapRoutes, MAP_ROUTE_ASSETS } from "./route-assets.js";
6
+ import { ACTIVE_DUTY_MAPS } from "./zones.js";
7
+ import type { MapRoute, MapRoutes } from "./routes.js";
8
+
9
+ const ROUTE_TYPES = new Set([
10
+ "primary_entry",
11
+ "secondary_entry",
12
+ "mid_connector",
13
+ "lurk_lane",
14
+ "rotation_cut",
15
+ ]);
16
+ const ROUTE_CONFIDENCE = new Set(["high", "medium", "low"]);
17
+
18
+ function loadRoutes(mapName: string): MapRoutes {
19
+ const path = fileURLToPath(new URL(`../map-routes/${mapName}.json`, import.meta.url));
20
+ return JSON.parse(readFileSync(path, "utf8")) as MapRoutes;
21
+ }
22
+
23
+ function expectedBombsite(route: MapRoute): string {
24
+ return route.bombsite === "a" ? "BombsiteA" : "BombsiteB";
25
+ }
26
+
27
+ describe("map route assets", () => {
28
+ it("route zones 不重复维护中文名", () => {
29
+ for (const [map, routes] of Object.entries(MAP_ROUTE_ASSETS)) {
30
+ const table = CALLOUT_DICT[map] ?? {};
31
+ for (const route of routes.routes) {
32
+ for (const zone of route.zones) {
33
+ expect("nameCn" in zone).toBe(false);
34
+ expect(table[zone.id] ?? zone.id).toBeTruthy();
35
+ }
36
+ }
37
+ }
38
+ });
39
+
40
+ it("provides at least one confirmed route for every active duty map", () => {
41
+ for (const mapName of ACTIVE_DUTY_MAPS) {
42
+ expect(loadRoutes(mapName).routes, mapName).not.toHaveLength(0);
43
+ }
44
+ });
45
+
46
+ it("exposes confirmed Dust2 routes through the public route asset helper", () => {
47
+ const dust2 = getMapRoutes("de_dust2");
48
+ expect(dust2?.mapName).toBe("de_dust2");
49
+ // 标注工具可继续新增动线,故只断言 4 条已确认动线存在(不做精确等于,避免脆断)。
50
+ expect(dust2?.routes.map((route) => route.id)).toEqual(
51
+ expect.arrayContaining(["a_long", "a_short", "b_tunnels", "b_mid_lower"]),
52
+ );
53
+ expect(getMapRoutes("de_cache")).toBeNull();
54
+ });
55
+
56
+ it("keeps route metadata and callout references internally consistent", () => {
57
+ for (const mapName of ACTIVE_DUTY_MAPS) {
58
+ const asset = loadRoutes(mapName);
59
+ const callouts = CALLOUT_DICT[mapName];
60
+ const ids = asset.routes.map((route) => route.id);
61
+
62
+ expect(asset.mapName).toBe(mapName);
63
+ expect(new Set(ids).size, `${mapName} route ids`).toBe(ids.length);
64
+
65
+ for (const route of asset.routes) {
66
+ const zoneIds = route.zones.map((zone) => zone.id);
67
+
68
+ expect(ROUTE_TYPES.has(route.type), `${mapName}/${route.id} type`).toBe(true);
69
+ expect(ROUTE_CONFIDENCE.has(route.confidence), `${mapName}/${route.id} confidence`).toBe(true);
70
+ expect(zoneIds[0], `${mapName}/${route.id} start`).toBe("TSpawn");
71
+ expect(zoneIds.at(-1), `${mapName}/${route.id} end`).toBe(expectedBombsite(route));
72
+ expect(new Set(zoneIds).size, `${mapName}/${route.id} repeated zones`).toBe(zoneIds.length);
73
+
74
+ for (const zone of route.zones) {
75
+ expect(zone.id in callouts, `${mapName}/${route.id}/${zone.id}`).toBe(true);
76
+ }
77
+ }
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,23 @@
1
+ import ancientRoutes from "../map-routes/de_ancient.json" with { type: "json" };
2
+ import anubisRoutes from "../map-routes/de_anubis.json" with { type: "json" };
3
+ import dust2Routes from "../map-routes/de_dust2.json" with { type: "json" };
4
+ import infernoRoutes from "../map-routes/de_inferno.json" with { type: "json" };
5
+ import mirageRoutes from "../map-routes/de_mirage.json" with { type: "json" };
6
+ import nukeRoutes from "../map-routes/de_nuke.json" with { type: "json" };
7
+ import overpassRoutes from "../map-routes/de_overpass.json" with { type: "json" };
8
+ import type { MapRoutes } from "./routes.js";
9
+ import type { ActiveDutyMap } from "./zones.js";
10
+
11
+ export const MAP_ROUTE_ASSETS: Record<ActiveDutyMap, MapRoutes> = {
12
+ de_ancient: ancientRoutes as MapRoutes,
13
+ de_anubis: anubisRoutes as MapRoutes,
14
+ de_dust2: dust2Routes as MapRoutes,
15
+ de_inferno: infernoRoutes as MapRoutes,
16
+ de_mirage: mirageRoutes as MapRoutes,
17
+ de_nuke: nukeRoutes as MapRoutes,
18
+ de_overpass: overpassRoutes as MapRoutes,
19
+ };
20
+
21
+ export function getMapRoutes(mapName: string): MapRoutes | null {
22
+ return (MAP_ROUTE_ASSETS as Record<string, MapRoutes | undefined>)[mapName] ?? null;
23
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { routeIndex, furthestRouteIndex, type MapRoute } from "./routes.js";
3
+
4
+ const aPalace: MapRoute = {
5
+ id: "a_palace",
6
+ name: "A 大厅",
7
+ type: "primary_entry",
8
+ bombsite: "a",
9
+ confidence: "high",
10
+ zones: [
11
+ { id: "TSpawn" },
12
+ { id: "PalaceAlley" },
13
+ { id: "TRamp" },
14
+ { id: "PalaceInterior" },
15
+ { id: "BombsiteA" },
16
+ ],
17
+ };
18
+
19
+ describe("routeIndex", () => {
20
+ it("returns advance index along the route", () => {
21
+ expect(routeIndex(aPalace, "TSpawn")).toBe(0);
22
+ expect(routeIndex(aPalace, "PalaceInterior")).toBe(3);
23
+ expect(routeIndex(aPalace, "BombsiteA")).toBe(4);
24
+ });
25
+
26
+ it("returns -1 for callouts off the route or nullish", () => {
27
+ expect(routeIndex(aPalace, "Apartments")).toBe(-1);
28
+ expect(routeIndex(aPalace, null)).toBe(-1);
29
+ expect(routeIndex(aPalace, undefined)).toBe(-1);
30
+ });
31
+ });
32
+
33
+ describe("furthestRouteIndex", () => {
34
+ it("takes the furthest controlled callout along the route", () => {
35
+ expect(furthestRouteIndex(aPalace, ["TSpawn", "TRamp", "PalaceAlley"])).toBe(2);
36
+ });
37
+
38
+ it("ignores callouts not on the route", () => {
39
+ expect(furthestRouteIndex(aPalace, ["Apartments", "Underpass"])).toBe(-1);
40
+ expect(furthestRouteIndex(aPalace, ["Apartments", "PalaceInterior"])).toBe(3);
41
+ });
42
+
43
+ it("returns -1 for an empty set", () => {
44
+ expect(furthestRouteIndex(aPalace, [])).toBe(-1);
45
+ });
46
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * map-routes — 进攻动线(T 方从匪家到包点的推进路径)
3
+ *
4
+ * 【归档 2026-06】route(有序动线)+zone(多边形) 的人工标注已停止演进,被
5
+ * `default-positions.ts`(callout 默认位归并)取代。本文件仅为 spatial shadow 层
6
+ * (phase/mapcontrol,当前发 null)保留,不再新增/修订动线资产。RR 现役零依赖。
7
+ *
8
+ * 定位:一条动线 = 一串**有序的 CS2 callout 区域名**(= replay `place` 列
9
+ * 对应的 placeDict 取值),从 T 出生区指向某个包点。控制进度 / 道具拖延都沿这条
10
+ * 一维序列度量。
11
+ *
12
+ * 边界:动线**只引用 callout 名字,不含任何坐标**——区域归属由 v3 replay `place`
13
+ * 列提供,本层不做几何。需要把一个 callout 再切细(如 Palace 分上下)时
14
+ * 才回到 `zones.ts` 的多边形层。本文件只定义结构 + 沿线定位,不算指标(指标在 core)。
15
+ *
16
+ * 数据:每张图一个 `packages/maps/map-routes/<map>.json`,由 `scripts/extract-routes.ts`
17
+ * 从真实 demo 的 T 方开局转移半自动挖出、人工确认。
18
+ */
19
+
20
+ /** 动线上的一个命名区域。 */
21
+ export interface RouteZone {
22
+ /** `lastPlaceName` 原始取值(如 "TSpawn" / "PalaceAlley" / "BombsiteA")。 */
23
+ id: string;
24
+ }
25
+
26
+ /** 动线类型——区分进攻线 vs 控制/入侵线。 */
27
+ export type RouteType =
28
+ | "primary_entry" // 主进攻线:A厅、B坡、Dust2 B洞 —— 直插包点的干道
29
+ | "secondary_entry" // 副进攻线:A小、B侧门、Inferno二楼 —— 次要/split 进点路径
30
+ | "mid_connector" // 中路 connector:中路→拱门/甜甜圈/连接 —— 价值在夹击与压缩防守
31
+ | "lurk_lane" // 单挂牵制线:控VIP/黑屋 —— 终点是控制区而非包点
32
+ | "rotation_cut"; // 断回防线:入侵警家/切轮转路线 —— 切断CT回防通道
33
+
34
+ /** 可信度——数据支撑程度。 */
35
+ export type RouteConfidence = "high" | "medium" | "low";
36
+
37
+ /** 一条进攻动线:从 T 出生区到某包点的有序 callout 序列。 */
38
+ export interface MapRoute {
39
+ /** 稳定唯一 id(如 "a_main" / "b_ramp")。 */
40
+ id: string;
41
+ /** 人类可读名(如 "A 厅" / "B 坡")。 */
42
+ name: string;
43
+ /** 动线类型。 */
44
+ type: RouteType;
45
+ /** 该动线指向的包点。 */
46
+ bombsite: "a" | "b";
47
+ /** 可信度标签。 */
48
+ confidence: RouteConfidence;
49
+ /**
50
+ * 推进顺序的 callout 序列(T 出生区 → 包点)。
51
+ * 第一个元素通常为 TSpawn、最后一个为 BombsiteA / BombsiteB。
52
+ */
53
+ zones: RouteZone[];
54
+ }
55
+
56
+ export interface MapRoutes {
57
+ /** 如 "de_mirage"。 */
58
+ mapName: string;
59
+ /** 标定版本,便于演进。 */
60
+ version: string;
61
+ routes: MapRoute[];
62
+ }
63
+
64
+ /** 提取动线上所有 zone id(供兼容字符串 API 使用)。 */
65
+ function zoneIds(route: MapRoute): string[] {
66
+ return route.zones.map((z) => z.id);
67
+ }
68
+
69
+ /**
70
+ * callout 在动线上的位置下标(0 = 起点/T 出生侧,越大越靠近包点),
71
+ * 不在该动线上返回 -1。
72
+ */
73
+ export function routeIndex(route: MapRoute, placeName: string | null | undefined): number {
74
+ if (!placeName) return -1;
75
+ return zoneIds(route).indexOf(placeName);
76
+ }
77
+
78
+ /**
79
+ * 一组当前被某方占有的 callout,沿该动线推进到的**最远下标**(-1 = 一个都不在线上)。
80
+ * 这是「区域控制进度」的核心原语:进攻方连续占有到 banana 顶 = 进度 = 该 callout 的下标。
81
+ */
82
+ export function furthestRouteIndex(
83
+ route: MapRoute,
84
+ controlledPlaceNames: Iterable<string>,
85
+ ): number {
86
+ let best = -1;
87
+ const ids = zoneIds(route);
88
+ for (const pl of controlledPlaceNames) {
89
+ const i = ids.indexOf(pl);
90
+ if (i > best) best = i;
91
+ }
92
+ return best;
93
+ }