@cs2dak/cohort 0.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import type { OpeningPattern } from "@cs2dak/core";
2
+
3
+ export type TacticalExecuteBucket = "rush" | "fast" | "mid" | "late";
4
+ export type EconomyEntry = "pistol" | "gun" | "anti_eco" | "force" | "semi" | "eco";
5
+
6
+ export interface TacticalPatternRow {
7
+ side: "t" | "ct";
8
+ targetSite: "a" | "b" | null;
9
+ matchId: string;
10
+ mapName: string;
11
+ teamKey: string;
12
+ /** 调用方可提供跨场 canonical identity;缺省时兼容回退到规范化原始名称。 */
13
+ teamIdentity?: string;
14
+ opponentIdentity?: string;
15
+ teamName: string;
16
+ opponentName: string;
17
+ economy: string;
18
+ opponentEconomy: string;
19
+ won: boolean;
20
+ roundNumber: number;
21
+ openingPattern: OpeningPattern;
22
+ siteEntries: {
23
+ a: { entrants: number; order: Array<{ entryCallout: string | null; entryChokeId?: string | null; routeFamilyId?: string | null }> };
24
+ b: { entrants: number; order: Array<{ entryCallout: string | null; entryChokeId?: string | null; routeFamilyId?: string | null }> };
25
+ };
26
+ plant: unknown | null;
27
+ grenades: Array<{ type: string; targetRegion: "a" | "b" | "mid" | "other" | "unknown" }>;
28
+ c4Route: { endRegion: "a" | "b" | "mid" | "other" | null; rotated: boolean } | null;
29
+ executeBucket: TacticalExecuteBucket | null;
30
+ }
31
+
32
+ export interface TacticalEntryEvidenceRoute {
33
+ site: "a" | "b";
34
+ combo: string;
35
+ roundCount: number;
36
+ percentOfCovered: number;
37
+ }
38
+
39
+ export interface TacticalEntryEvidence {
40
+ coveredRounds: number;
41
+ totalRounds: number;
42
+ coveragePercent: number;
43
+ routes: TacticalEntryEvidenceRoute[];
44
+ }
45
+
46
+ export interface TacticalCluster {
47
+ id: string;
48
+ mapName: string;
49
+ side: TacticalPatternRow["side"];
50
+ economyEntry: EconomyEntry;
51
+ /** 当前事实中可用的跨场队伍身份(规范化 teamName)。 */
52
+ teamIdentity: string;
53
+ teamName: string;
54
+ opponentNames: string[];
55
+ opponentIdentities: string[];
56
+ /** 真实 OpeningPattern 的区域人数与 spread,不含任何最终打点事实。 */
57
+ openingIntent: Pick<OpeningPattern, "regionCounts" | "spread">;
58
+ /** 真实默认位人数结构;精确人数是开局身份的一部分。 */
59
+ positionGroupCounts: Record<string, number>;
60
+ primaryCategory: string;
61
+ openingSignature: string;
62
+ entryEvidence: TacticalEntryEvidence;
63
+ roundCount: number;
64
+ winRatePercent: number | null;
65
+ plantRatePercent: number | null;
66
+ rounds: Array<{ matchId: string; roundNumber: number; teamKey: string; won: boolean; economy: string; planted: boolean }>;
67
+ }
68
+
69
+ export function economyEntryOf(economy: string, opponentEconomy: string): EconomyEntry {
70
+ if (economy === "pistol") return "pistol";
71
+ if (economy === "full") return opponentEconomy === "full" ? "gun" : "anti_eco";
72
+ if (economy === "force") return "force";
73
+ if (economy === "semi") return "semi";
74
+ return "eco";
75
+ }
76
+
77
+ export function defaultsBasisKey(defaults: Record<string, number>): string {
78
+ return Object.entries(defaults)
79
+ .filter(([, count]) => count > 0)
80
+ .sort(([a], [b]) => a.localeCompare(b))
81
+ .map(([id, count]) => `${id}:${count}`)
82
+ .join("|");
83
+ }
84
+ export const advancedBasisKey = defaultsBasisKey;
85
+
86
+ /** 稳定默认位身份:按位置责任组 id 排序,并保留真实人数结构。 */
87
+ export function positionGroupSetKey(groups: Record<string, number>): string {
88
+ return defaultsBasisKey(groups);
89
+ }
90
+
91
+ export function openingIntentKey(pattern: OpeningPattern): string {
92
+ const { a, mid, b } = pattern.regionCounts;
93
+ return `${a}A-${mid}MID-${b}B:${pattern.spread}`;
94
+ }
95
+
96
+ function teamIdentityOf(row: TacticalPatternRow): string {
97
+ return row.teamIdentity ?? row.teamName.trim().toLowerCase();
98
+ }
99
+
100
+ function opponentIdentityOf(row: TacticalPatternRow): string {
101
+ return row.opponentIdentity ?? row.opponentName.trim().toLowerCase();
102
+ }
103
+
104
+ export function openingPatternKey(row: TacticalPatternRow): string {
105
+ return [row.mapName, row.side, openingIntentKey(row.openingPattern), positionGroupSetKey(row.openingPattern.positionGroupCounts)].join(":");
106
+ }
107
+
108
+ /** 单回合真实进点证据。缺少目标点或入口时返回 null,不用 fallback 猜测。 */
109
+ export function entryEvidenceKey(row: TacticalPatternRow): { site: "a" | "b"; combo: string } | null {
110
+ if (row.side !== "t") return null;
111
+ const site = row.targetSite;
112
+ if (!site) return null;
113
+ const ids = new Set<string>();
114
+ for (const occurrence of row.siteEntries[site].order) {
115
+ const id = occurrence.entryChokeId ?? occurrence.routeFamilyId;
116
+ if (id) ids.add(id);
117
+ }
118
+ if (ids.size === 0) return null;
119
+ return { site, combo: [...ids].sort().join("+") };
120
+ }
121
+
122
+ /** 兼容既有调用:该函数现在只读取真实进点 evidence,不参与聚类 key。 */
123
+ export function chokeComboOf(row: TacticalPatternRow, site: "a" | "b" | null): string | null {
124
+ const evidence = entryEvidenceKey(row);
125
+ return evidence?.site === site ? evidence.combo : null;
126
+ }
127
+
128
+ export function tacticalClusterKey(row: TacticalPatternRow): string {
129
+ return [
130
+ row.side,
131
+ row.mapName,
132
+ teamIdentityOf(row),
133
+ economyEntryOf(row.economy, row.opponentEconomy),
134
+ openingIntentKey(row.openingPattern),
135
+ positionGroupSetKey(row.openingPattern.positionGroupCounts) || "-",
136
+ ].join("|");
137
+ }
138
+
139
+ function primaryCategoryOf(pattern: OpeningPattern): string {
140
+ const { a, mid, b } = pattern.regionCounts;
141
+ const max = Math.max(a, mid, b);
142
+ const leaders = [a === max ? "A侧" : null, mid === max ? "中路" : null, b === max ? "B侧" : null].filter(Boolean);
143
+ return leaders.length === 1 ? `${leaders[0]}控图` : "均衡控图";
144
+ }
145
+
146
+ export function buildTacticalClusters(rows: readonly TacticalPatternRow[]): TacticalCluster[] {
147
+ const clusters = new Map<string, TacticalCluster>();
148
+ const entryCounts = new Map<string, Map<string, number>>();
149
+ for (const row of rows) {
150
+ const id = tacticalClusterKey(row);
151
+ const cluster: TacticalCluster = clusters.get(id) ?? {
152
+ id,
153
+ mapName: row.mapName,
154
+ side: row.side,
155
+ economyEntry: economyEntryOf(row.economy, row.opponentEconomy),
156
+ teamIdentity: teamIdentityOf(row),
157
+ teamName: row.teamName,
158
+ opponentNames: [row.opponentName],
159
+ opponentIdentities: [opponentIdentityOf(row)],
160
+ openingIntent: {
161
+ regionCounts: { ...row.openingPattern.regionCounts },
162
+ spread: row.openingPattern.spread,
163
+ },
164
+ positionGroupCounts: { ...row.openingPattern.positionGroupCounts },
165
+ primaryCategory: primaryCategoryOf(row.openingPattern),
166
+ openingSignature: openingPatternKey(row),
167
+ entryEvidence: { coveredRounds: 0, totalRounds: 0, coveragePercent: 0, routes: [] },
168
+ roundCount: 0,
169
+ winRatePercent: null,
170
+ plantRatePercent: null,
171
+ rounds: [],
172
+ };
173
+ cluster.roundCount += 1;
174
+ if (!cluster.opponentNames.includes(row.opponentName)) {
175
+ cluster.opponentNames.push(row.opponentName);
176
+ cluster.opponentNames.sort((a, b) => a.localeCompare(b));
177
+ }
178
+ const opponentIdentity = opponentIdentityOf(row);
179
+ if (!cluster.opponentIdentities.includes(opponentIdentity)) cluster.opponentIdentities.push(opponentIdentity);
180
+ cluster.rounds.push({
181
+ matchId: row.matchId,
182
+ roundNumber: row.roundNumber,
183
+ teamKey: row.teamKey,
184
+ won: row.won,
185
+ economy: row.economy,
186
+ planted: row.plant != null,
187
+ });
188
+ const evidence = entryEvidenceKey(row);
189
+ if (evidence) {
190
+ const counts = entryCounts.get(id) ?? new Map<string, number>();
191
+ const routeKey = `${evidence.site}:${evidence.combo}`;
192
+ counts.set(routeKey, (counts.get(routeKey) ?? 0) + 1);
193
+ entryCounts.set(id, counts);
194
+ }
195
+ clusters.set(id, cluster);
196
+ }
197
+ const percent = (numerator: number, denominator: number) =>
198
+ denominator > 0 ? Math.round((numerator / denominator) * 1000) / 10 : 0;
199
+ return [...clusters.values()]
200
+ .map((cluster) => {
201
+ const counts = entryCounts.get(cluster.id) ?? new Map<string, number>();
202
+ const coveredRounds = [...counts.values()].reduce((sum, count) => sum + count, 0);
203
+ return {
204
+ ...cluster,
205
+ winRatePercent: percent(cluster.rounds.filter((round) => round.won).length, cluster.roundCount),
206
+ plantRatePercent: percent(cluster.rounds.filter((round) => round.planted).length, cluster.roundCount),
207
+ entryEvidence: {
208
+ coveredRounds,
209
+ totalRounds: cluster.roundCount,
210
+ coveragePercent: percent(coveredRounds, cluster.roundCount),
211
+ routes: [...counts.entries()]
212
+ .map(([key, roundCount]) => ({
213
+ site: key.slice(0, 1) as "a" | "b",
214
+ combo: key.slice(2),
215
+ roundCount,
216
+ percentOfCovered: percent(roundCount, coveredRounds),
217
+ }))
218
+ .sort((a, b) => b.roundCount - a.roundCount || a.site.localeCompare(b.site) || a.combo.localeCompare(b.combo)),
219
+ },
220
+ };
221
+ })
222
+ .sort((a, b) => b.roundCount - a.roundCount || a.id.localeCompare(b.id));
223
+ }