@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.
package/LICENSE CHANGED
@@ -1,3 +1,10 @@
1
+ Licensing note / 适用范围
2
+ -------------------------
3
+ The MIT License below applies to this repository EXCEPT apps/dak-studio/,
4
+ which is licensed under the GNU AGPL-3.0-only (see apps/dak-studio/LICENSE).
5
+ Ecosystem packages (@cs2dak/*, python exporter) are MIT; the DAK Studio
6
+ product is AGPL. Third-party attributions: THIRD-PARTY-NOTICES.md.
7
+
1
8
  MIT License
2
9
 
3
10
  Copyright (c) 2026 Starfie1d
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@cs2dak/cohort",
3
- "version": "0.2.1",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@rivalhub/rival-rating": "^0.1.0",
11
- "@cs2dak/contract": "0.2.1",
12
- "@cs2dak/core": "0.2.1"
10
+ "@rivalhub/rival-rating": "^0.3.0",
11
+ "@cs2dak/contract": "1.1.0",
12
+ "@cs2dak/core": "2.0.0"
13
13
  },
14
14
  "files": [
15
- "src/index.ts"
15
+ "src"
16
16
  ],
17
17
  "publishConfig": {
18
18
  "access": "public"
@@ -0,0 +1,150 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { beforeAll, describe, expect, it } from "vitest";
5
+ import { derivePlayerWeaponHighlights, deriveRRIndicators, loadDemoPackageFromZip } from "@cs2dak/core";
6
+ import type { DemoPackage } from "@cs2dak/contract";
7
+ import { buildSeasonCohort } from "./index";
8
+
9
+ const fixtureDir = fileURLToPath(new URL("../../../fixtures/input/cohort", import.meta.url));
10
+ const integrationTimeoutMs = 90_000; // CI 2-core runner 加载 3 场 ZIP + cohort 分析比本机慢 5–10×
11
+ let demos: Awaited<ReturnType<typeof loadCohortFixtures>> | null = null;
12
+ let baselineBundle: ReturnType<typeof buildSeasonCohort> | null = null;
13
+
14
+ async function loadCohortFixtures() {
15
+ const names = (await readdir(fixtureDir)).filter((name) => name.endsWith(".zip")).sort();
16
+ return Promise.all(
17
+ names.map(async (name) => ({
18
+ matchId: name.replace(/\.zip$/, ""),
19
+ pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
20
+ }))
21
+ );
22
+ }
23
+
24
+ beforeAll(async () => {
25
+ demos = await loadCohortFixtures();
26
+ baselineBundle = buildSeasonCohort(demos);
27
+ }, integrationTimeoutMs);
28
+
29
+ describe("buildSeasonCohort", () => {
30
+ it("builds a season bundle from multiple sanitized demo packages", () => {
31
+ const bundle = baselineBundle!;
32
+ const uniquePlayers = new Set(demos!.flatMap((demo) => demo.pkg.players.map((player) => player.steamId64)));
33
+
34
+ expect(demos!).toHaveLength(3);
35
+ expect(bundle.matchCount).toBe(3);
36
+ expect(bundle.version).toBe("cs2-demo-analysis-kit/cohort-1.0");
37
+ expect(bundle.provenance.matches).toHaveLength(3);
38
+ expect(bundle.players).toHaveLength(uniquePlayers.size);
39
+ expect(bundle.players.length).toBeGreaterThan(10);
40
+ expect(bundle.players.every((row) => row.prism?.mapCount === row.mapCount)).toBe(true);
41
+ expect(bundle.players.every((row) => row.playerKey.startsWith("steam:"))).toBe(true);
42
+ });
43
+
44
+ it("merges the same steamId64 across matches and sums season counts", () => {
45
+ const bundle = baselineBundle!;
46
+ const repeated = bundle.players.find((row) => row.mapCount > 1);
47
+ expect(repeated).toBeDefined();
48
+
49
+ const expectedKills = demos!.reduce((total, demo) => {
50
+ const row = deriveRRIndicators(demo.pkg).find((indicator) => repeated!.steamIds.includes(indicator.steamId64));
51
+ return total + (row?.kills ?? 0);
52
+ }, 0);
53
+ const expectedRounds = demos!.reduce((total, demo) => {
54
+ const row = deriveRRIndicators(demo.pkg).find((indicator) => repeated!.steamIds.includes(indicator.steamId64));
55
+ return total + (row?.totalRounds ?? 0);
56
+ }, 0);
57
+
58
+ expect(repeated!.indicators.kills).toBe(expectedKills);
59
+ expect(repeated!.indicators.totalRounds).toBe(expectedRounds);
60
+ expect(repeated!.perMatch).toHaveLength(repeated!.mapCount);
61
+ });
62
+
63
+ it("can merge borrowed Steam accounts through an external identity map", () => {
64
+ const firstSteamId = demos![0]!.pkg.players[0]!.steamId64;
65
+ const borrowedSteamId = demos!
66
+ .slice(1)
67
+ .flatMap((demo) => demo.pkg.players.map((player) => player.steamId64))
68
+ .find((steamId) => steamId !== firstSteamId)!;
69
+ const bundle = buildSeasonCohort(demos!, {
70
+ identityMap: {
71
+ [firstSteamId]: {
72
+ playerKey: "user:rivalhub-user-1",
73
+ userId: "rivalhub-user-1",
74
+ displayName: "Unified Player"
75
+ },
76
+ [borrowedSteamId]: {
77
+ playerKey: "user:rivalhub-user-1",
78
+ userId: "rivalhub-user-1",
79
+ displayName: "Unified Player"
80
+ }
81
+ }
82
+ });
83
+ const merged = bundle.players.find((row) => row.playerKey === "user:rivalhub-user-1");
84
+
85
+ expect(bundle.players).toHaveLength(baselineBundle!.players.length - 1);
86
+ expect(merged).toBeDefined();
87
+ expect(merged!.name).toBe("Unified Player");
88
+ expect(merged!.externalUserId).toBe("rivalhub-user-1");
89
+ expect(merged!.steamIds).toEqual([borrowedSteamId, firstSteamId].sort());
90
+ expect([...new Set(merged!.perMatch.map((row) => row.steamId64))].sort()).toEqual([borrowedSteamId, firstSteamId].sort());
91
+ expect(merged!.indicators.steamId64).toBe("user:rivalhub-user-1");
92
+ expect(merged!.prism?.steamId64).toBe("user:rivalhub-user-1");
93
+ });
94
+
95
+ it("scores season accountRR against the same frozen pro baseline as single matches", () => {
96
+ const mean = baselineBundle!.players.reduce((sum, row) => sum + row.accountRR, 0) / baselineBundle!.players.length;
97
+
98
+ expect(mean).toBeGreaterThan(0);
99
+ expect(mean).not.toBeCloseTo(1, 2);
100
+ expect(baselineBundle!.players.some((row) => row.accountRR > 1)).toBe(true);
101
+ expect(baselineBundle!.players.some((row) => row.accountRR < 1)).toBe(true);
102
+ });
103
+
104
+ it("recomputes rate fields from summed counts and rounds", () => {
105
+ const bundle = baselineBundle!;
106
+ const repeated = bundle.players.find((row) => row.mapCount > 1);
107
+ expect(repeated).toBeDefined();
108
+
109
+ const sourceRows = demos!
110
+ .flatMap((demo) => deriveRRIndicators(demo.pkg))
111
+ .filter((row) => repeated!.steamIds.includes(row.steamId64));
112
+ const damage = sourceRows.reduce((sum, row) => sum + row.adr * row.totalRounds, 0);
113
+ const rounds = sourceRows.reduce((sum, row) => sum + row.totalRounds, 0);
114
+ const plainAverageAdr = sourceRows.reduce((sum, row) => sum + row.adr, 0) / sourceRows.length;
115
+
116
+ expect(repeated!.indicators.adr).toBeCloseTo(damage / rounds, 2);
117
+ expect(repeated!.indicators.adr).not.toBeCloseTo(plainAverageAdr, 4);
118
+ });
119
+
120
+ it("lowers confidence when context sources are missing", () => {
121
+ const complete = baselineBundle!;
122
+ const stripped = buildSeasonCohort(
123
+ demos!.map((demo) => ({
124
+ matchId: demo.matchId,
125
+ pkg: { ...demo.pkg, playerEconomies: [], rounds: [] } satisfies DemoPackage
126
+ }))
127
+ );
128
+
129
+ expect(complete.players[0]?.confidence).toBeGreaterThan(stripped.players[0]?.confidence ?? 1);
130
+ expect(stripped.players.every((row) => row.confidence >= 0 && row.confidence <= 1)).toBe(true);
131
+ });
132
+
133
+ it("sums weapon distributions and highlight counts across identities", () => {
134
+ const bundle = baselineBundle!;
135
+ const repeated = bundle.players.find((row) => row.mapCount > 1)!;
136
+ const source = demos!
137
+ .flatMap((demo) => derivePlayerWeaponHighlights(demo.pkg))
138
+ .filter((row) => repeated.steamIds.includes(row.steamId64));
139
+ const expectedTotalKills = source.reduce((sum, row) => sum + row.totalKills, 0);
140
+ const expectedNoScopes = source.reduce((sum, row) => sum + (row.highlights.noScopeKills ?? 0), 0);
141
+ const expectedWeaponHeadshots = source
142
+ .flatMap((row) => row.weapons)
143
+ .reduce((sum, weapon) => sum + weapon.headshotKills, 0);
144
+
145
+ expect(repeated.weaponHighlights.totalKills).toBe(expectedTotalKills);
146
+ expect(repeated.weaponHighlights.weapons.reduce((sum, row) => sum + row.kills, 0)).toBe(expectedTotalKills);
147
+ expect(repeated.weaponHighlights.weapons.reduce((sum, row) => sum + row.headshotKills, 0)).toBe(expectedWeaponHeadshots);
148
+ expect(repeated.weaponHighlights.highlights.noScopeKills).toBe(expectedNoScopes);
149
+ });
150
+ });
package/src/index.ts CHANGED
@@ -1,31 +1,77 @@
1
1
  import {
2
- deriveAccountSignalsV2,
3
- deriveRRIndicators,
4
- computeAccountRatingsV2
2
+ deriveRRSignals,
3
+ derivePlayerWeaponHighlights,
4
+ deriveRRIndicators
5
5
  } from "@cs2dak/core";
6
6
  import {
7
7
  seasonCohortBundleSchema,
8
8
  type AccountContextAvailability,
9
- type AccountSignalsV2,
10
9
  type DemoPackage,
11
10
  type RRIndicators,
11
+ type RRSignals,
12
+ type PlayerWeaponHighlightFacts,
12
13
  type SeasonCohortBundle,
13
14
  type TeamKey,
14
- type ValueAccountsWeights
15
+ type RRSixAccountWeights
15
16
  } from "@cs2dak/contract";
16
17
  import {
17
- computeCohortAccountsRR,
18
+ computeFrozenProBaselineRR,
18
19
  computePrism,
19
20
  computeRR,
20
21
  prismWeightsV1,
22
+ rrSixAccountProBaselineV0,
21
23
  rrToPercentile,
22
- rrValueAccountsV2Lite,
23
- rrWeightsV1,
24
+ hltv2BaselineWeightsV1,
25
+ rrSixAccountWeightsV1,
26
+ type ProBaselineConfig,
24
27
  type PrismComputeInput,
25
28
  type PrismWeights,
26
29
  type RRWeights
27
30
  } from "@rivalhub/rival-rating";
28
31
 
32
+ export { buildOpeningPatternClusters } from "./patterns.js";
33
+ export type { OpeningPatternCluster, OpeningPatternInput, OpeningPatternOptions } from "./patterns.js";
34
+ export {
35
+ advancedBasisKey,
36
+ buildTacticalClusters,
37
+ chokeComboOf,
38
+ defaultsBasisKey,
39
+ economyEntryOf,
40
+ entryEvidenceKey,
41
+ openingIntentKey,
42
+ openingPatternKey,
43
+ positionGroupSetKey,
44
+ tacticalClusterKey,
45
+ } from "./tactical-clusters.js";
46
+ export type {
47
+ EconomyEntry,
48
+ TacticalCluster,
49
+ TacticalEntryEvidence,
50
+ TacticalEntryEvidenceRoute,
51
+ TacticalExecuteBucket,
52
+ TacticalPatternRow,
53
+ } from "./tactical-clusters.js";
54
+ export {
55
+ buildPlayerMapRoleEvidence,
56
+ buildTeamMapResponsibilityEvidence,
57
+ MAP_ROLE_MODEL_VERSION,
58
+ MAP_ROLE_THRESHOLDS,
59
+ type MapRoleEvidenceFacts,
60
+ type MapRoleEvidenceOptions,
61
+ } from "./map-role-evidence.js";
62
+ export {
63
+ buildTResponsibilityResearchProjections,
64
+ scoreFrozenTResponsibilityFeatures,
65
+ T_RESPONSIBILITY_RESEARCH_MODEL_ID,
66
+ T_RESPONSIBILITY_RESEARCH_THRESHOLD,
67
+ } from "./t-role-research-projection.js";
68
+
69
+
70
+ /**
71
+ * "Season" 是历史命名:cohort = 任意一组比赛的跨场聚合,不限于赛季。
72
+ * 同一 API 服务于赛季排行(RivalHub)、单赛事统计(主办方)、个人全量复盘(玩家)
73
+ * 与自选职业 demo 集(分析师);范围筛选由调用方决定(如 DAK Studio 的聚合范围过滤)。
74
+ */
29
75
  export interface SeasonCohortInput {
30
76
  matchId: string;
31
77
  pkg: DemoPackage;
@@ -33,11 +79,22 @@ export interface SeasonCohortInput {
33
79
 
34
80
  export interface SeasonCohortOptions {
35
81
  rrWeights?: RRWeights;
36
- valueWeights?: ValueAccountsWeights;
82
+ valueWeights?: RRSixAccountWeights;
37
83
  prismWeights?: PrismWeights;
38
84
  identityMap?: PlayerIdentityMap;
39
85
  }
40
86
 
87
+ export interface SeasonCohortFactRow {
88
+ matchId: string;
89
+ sourceDemoHash: string | null;
90
+ steamId64: string;
91
+ playerName: string;
92
+ teamKey: TeamKey;
93
+ signals: RRSignals;
94
+ indicators: RRIndicators;
95
+ weaponHighlight: PlayerWeaponHighlightFacts | null;
96
+ }
97
+
41
98
  export interface PlayerIdentity {
42
99
  playerKey: string;
43
100
  displayName?: string;
@@ -55,8 +112,9 @@ interface PlayerAccumulator {
55
112
  names: Map<string, number>;
56
113
  teamKeys: Set<TeamKey>;
57
114
  mapCount: number;
58
- signals: AccountSignalsV2[];
115
+ signals: RRSignals[];
59
116
  indicators: RRIndicators[];
117
+ weaponHighlights: PlayerWeaponHighlightFacts[];
60
118
  perMatch: Array<{ matchId: string; steamId64: string; accountRR: number; rrV1: number }>;
61
119
  }
62
120
 
@@ -64,55 +122,87 @@ export function buildSeasonCohort(
64
122
  demos: SeasonCohortInput[],
65
123
  opts: SeasonCohortOptions = {}
66
124
  ): SeasonCohortBundle {
67
- const rrWeights = opts.rrWeights ?? (rrWeightsV1 as unknown as RRWeights);
68
- const valueWeights = opts.valueWeights ?? (rrValueAccountsV2Lite as unknown as ValueAccountsWeights);
69
- const prismWeights = opts.prismWeights ?? (prismWeightsV1 as unknown as PrismWeights);
70
- const identityMap = opts.identityMap ?? {};
71
- const players = new Map<string, PlayerAccumulator>();
72
-
125
+ const rrWeights = opts.rrWeights ?? (hltv2BaselineWeightsV1 as unknown as RRWeights);
126
+ const valueWeights = opts.valueWeights ?? (rrSixAccountWeightsV1 as unknown as RRSixAccountWeights);
127
+ const rows: SeasonCohortFactRow[] = [];
73
128
  for (const demo of demos) {
74
- const signals = deriveAccountSignalsV2(demo.pkg);
129
+ const signals = deriveRRSignals(demo.pkg);
75
130
  const indicators = deriveRRIndicators(demo.pkg);
76
- const matchAccounts = computeAccountRatingsV2(demo.pkg);
77
- const matchAccountBySteamId = new Map(matchAccounts.map((row) => [row.signals.steamId64, row.rr]));
78
- const rrBySteamId = new Map(indicators.map((row) => [row.steamId64, computeRR(row, rrWeights)]));
79
-
131
+ const weaponHighlights = derivePlayerWeaponHighlights(demo.pkg);
132
+ const signalBySteamId = new Map(signals.map((row) => [row.steamId64, row]));
133
+ const indicatorBySteamId = new Map(indicators.map((row) => [row.steamId64, row]));
134
+ const weaponHighlightBySteamId = new Map(weaponHighlights.map((row) => [row.steamId64, row]));
80
135
  for (const player of demo.pkg.players) {
81
- const signal = signals.find((row) => row.steamId64 === player.steamId64);
82
- const indicator = indicators.find((row) => row.steamId64 === player.steamId64);
136
+ const signal = signalBySteamId.get(player.steamId64);
137
+ const indicator = indicatorBySteamId.get(player.steamId64);
83
138
  if (!signal || !indicator) continue;
84
- const identity = resolveIdentity(player.steamId64, identityMap);
85
-
86
- const acc = getOrInit(players, identity.playerKey, () => ({
87
- playerKey: identity.playerKey,
88
- steamIds: new Set<string>(),
89
- primarySteamId64: player.steamId64,
90
- externalUserId: identity.userId ?? null,
91
- displayName: identity.displayName ?? null,
92
- names: new Map<string, number>(),
93
- teamKeys: new Set<TeamKey>(),
94
- mapCount: 0,
95
- signals: [],
96
- indicators: [],
97
- perMatch: []
98
- }));
99
-
100
- acc.steamIds.add(player.steamId64);
101
- if (!acc.externalUserId && identity.userId) acc.externalUserId = identity.userId;
102
- if (!acc.displayName && identity.displayName) acc.displayName = identity.displayName;
103
- acc.names.set(player.name, (acc.names.get(player.name) ?? 0) + 1);
104
- acc.teamKeys.add(player.teamKey);
105
- acc.mapCount += 1;
106
- acc.signals.push(signal);
107
- acc.indicators.push(indicator);
108
- acc.perMatch.push({
139
+ rows.push({
109
140
  matchId: demo.matchId,
141
+ sourceDemoHash: demo.pkg.manifest.demo?.hash ?? null,
110
142
  steamId64: player.steamId64,
111
- accountRR: round(matchAccountBySteamId.get(player.steamId64)?.rr ?? 0, 3),
112
- rrV1: round(rrBySteamId.get(player.steamId64)?.rr ?? 0, 3)
143
+ playerName: player.name,
144
+ teamKey: player.teamKey,
145
+ signals: signal,
146
+ indicators: indicator,
147
+ weaponHighlight: weaponHighlightBySteamId.get(player.steamId64) ?? null
113
148
  });
114
149
  }
115
150
  }
151
+ return buildSeasonCohortFromRows(rows, {
152
+ ...opts,
153
+ rrWeights,
154
+ valueWeights,
155
+ matchCount: demos.length
156
+ });
157
+ }
158
+
159
+ export function buildSeasonCohortFromRows(
160
+ rows: SeasonCohortFactRow[],
161
+ opts: SeasonCohortOptions & { matchCount?: number } = {}
162
+ ): SeasonCohortBundle {
163
+ const rrWeights = opts.rrWeights ?? (hltv2BaselineWeightsV1 as unknown as RRWeights);
164
+ const valueWeights = opts.valueWeights ?? (rrSixAccountWeightsV1 as unknown as RRSixAccountWeights);
165
+ const proBaseline = rrSixAccountProBaselineV0 as unknown as ProBaselineConfig;
166
+ const prismWeights = opts.prismWeights ?? (prismWeightsV1 as unknown as PrismWeights);
167
+ const identityMap = opts.identityMap ?? {};
168
+ const players = new Map<string, PlayerAccumulator>();
169
+
170
+ for (const row of rows) {
171
+ const identity = resolveIdentity(row.steamId64, identityMap);
172
+
173
+ const acc = getOrInit(players, identity.playerKey, () => ({
174
+ playerKey: identity.playerKey,
175
+ steamIds: new Set<string>(),
176
+ primarySteamId64: row.steamId64,
177
+ externalUserId: identity.userId ?? null,
178
+ displayName: identity.displayName ?? null,
179
+ names: new Map<string, number>(),
180
+ teamKeys: new Set<TeamKey>(),
181
+ mapCount: 0,
182
+ signals: [],
183
+ indicators: [],
184
+ weaponHighlights: [],
185
+ perMatch: []
186
+ }));
187
+
188
+ acc.steamIds.add(row.steamId64);
189
+ if (!acc.externalUserId && identity.userId) acc.externalUserId = identity.userId;
190
+ if (!acc.displayName && identity.displayName) acc.displayName = identity.displayName;
191
+ acc.names.set(row.playerName, (acc.names.get(row.playerName) ?? 0) + 1);
192
+ acc.teamKeys.add(row.teamKey);
193
+ acc.mapCount += 1;
194
+ acc.signals.push(row.signals);
195
+ acc.indicators.push(row.indicators);
196
+ if (row.weaponHighlight) acc.weaponHighlights.push(row.weaponHighlight);
197
+ const accountRR = computeFrozenProBaselineRR(row.signals, valueWeights, proBaseline);
198
+ const rrV1 = computeRR(row.indicators, rrWeights);
199
+ acc.perMatch.push({
200
+ matchId: row.matchId,
201
+ steamId64: row.steamId64,
202
+ accountRR: round(accountRR.rr, 3),
203
+ rrV1: round(rrV1.rr, 3)
204
+ });
205
+ }
116
206
 
117
207
  const seasonRows = [...players.values()].map((acc) => {
118
208
  const signals = aggregateAccountSignals(acc.playerKey, acc.signals);
@@ -121,15 +211,11 @@ export function buildSeasonCohort(
121
211
  return { acc, signals, indicators, rrV1 };
122
212
  });
123
213
 
124
- // 账户平衡(标准化 + 残差化)由 rival-rating 的 computeCohortAccountsRR 拥有(公式归属)。
125
- // scale 对齐到 rrV1 的离散度,使 v2 与已被 HLTV 逆向验证的 v1 量纲可比。详见该库 docs/rr-v2.md
126
- // 与本仓库 docs/design/cohort.md。
127
- const targetStd = stdev(seasonRows.map((row) => row.rrV1.rr));
128
214
  const balancedByKey = new Map(
129
- computeCohortAccountsRR(seasonRows.map((row) => row.signals), valueWeights, { targetStd }).map((b) => [
130
- b.steamId64,
131
- b
132
- ])
215
+ seasonRows.map((row) => {
216
+ const scored = computeFrozenProBaselineRR(row.signals, valueWeights, proBaseline);
217
+ return [scored.steamId64, scored] as const;
218
+ })
133
219
  );
134
220
  const rrV1Scores = seasonRows.map((row) => row.rrV1.rr);
135
221
  const prismInputs: PrismComputeInput[] = seasonRows.map((row) => ({
@@ -140,9 +226,17 @@ export function buildSeasonCohort(
140
226
  const prismResults = new Map(computePrism(prismInputs, prismWeights).map((row) => [row.steamId64, row]));
141
227
 
142
228
  return seasonCohortBundleSchema.parse({
143
- version: "cs2-demo-analysis-kit/season-0.1",
144
- matchCount: demos.length,
229
+ version: "cs2-demo-analysis-kit/cohort-1.0",
230
+ matchCount: opts.matchCount ?? new Set(rows.map((row) => row.matchId)).size,
145
231
  weightsVersion: `${rrWeights.version}+${valueWeights.version}+${prismWeights.version}`,
232
+ provenance: {
233
+ cohortVersion: "cs2-demo-analysis-kit/cohort-1.0",
234
+ sourceSchemaVersion: "cs2-demo-format/3.0",
235
+ matches: [...new Map(rows.map((row) => [row.matchId, {
236
+ matchId: row.matchId,
237
+ sourceDemoHash: row.sourceDemoHash
238
+ }])).values()]
239
+ },
146
240
  players: seasonRows
147
241
  .map((row) => {
148
242
  const balanced = balancedByKey.get(row.acc.playerKey)!;
@@ -159,11 +253,13 @@ export function buildSeasonCohort(
159
253
  rrV1: round(row.rrV1.rr, 3),
160
254
  rrV1Percentile: round(rrToPercentile(rrV1Scores, row.rrV1.rr), 1),
161
255
  indicators: row.indicators,
256
+ weaponHighlights: aggregateWeaponHighlights(row.acc.playerKey, row.acc.weaponHighlights),
162
257
  accountRR: round(balanced.rr, 3),
163
258
  accountRRRaw: round(balanced.rrRaw, 3),
164
259
  accountBreakdown: {
165
260
  combat: round(balanced.accounts.combat, 4),
166
261
  trade: round(balanced.accounts.trade, 4),
262
+ mapControl: round(balanced.accounts.mapControl, 4),
167
263
  clutch: round(balanced.accounts.clutch, 4),
168
264
  objective: round(balanced.accounts.objective, 4),
169
265
  utility: round(balanced.accounts.utility, 4)
@@ -174,7 +270,6 @@ export function buildSeasonCohort(
174
270
  perMatch: row.acc.perMatch.sort((a, b) => a.matchId.localeCompare(b.matchId))
175
271
  };
176
272
  })
177
- .sort((a, b) => b.accountRR - a.accountRR || b.rrV1 - a.rrV1 || a.name.localeCompare(b.name))
178
273
  });
179
274
  }
180
275
 
@@ -185,11 +280,11 @@ function resolveIdentity(steamId64: string, identityMap: PlayerIdentityMap): Pla
185
280
  return value;
186
281
  }
187
282
 
188
- function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): AccountSignalsV2 {
283
+ function aggregateAccountSignals(steamId64: string, rows: RRSignals[]): RRSignals {
189
284
  return {
190
285
  steamId64,
191
286
  rounds: sum(rows, (row) => row.rounds),
192
- sourceVersion: "cs2-demo-analysis-kit/season-0.1",
287
+ sourceVersion: "cs2-demo-analysis-kit/cohort-1.0",
193
288
  combat: {
194
289
  kills: sum(rows, (row) => row.combat.kills),
195
290
  deaths: sum(rows, (row) => row.combat.deaths),
@@ -212,7 +307,15 @@ function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): A
212
307
  tradeKills: sum(rows, (row) => row.trade.tradeKills),
213
308
  tradedDeaths: sum(rows, (row) => row.trade.tradedDeaths),
214
309
  deaths: sum(rows, (row) => row.trade.deaths),
215
- tradedOpeningDeaths: sumNullable(rows.map((row) => row.trade.tradedOpeningDeaths))
310
+ tradedOpeningDeaths: sumNullable(rows.map((row) => row.trade.tradedOpeningDeaths)),
311
+ strategicIsolationDeaths: sumNullable(rows.map((row) => row.trade.strategicIsolationDeaths))
312
+ },
313
+ mapControl: {
314
+ uniqueStrategicControlSeconds: sumNullable(rows.map((row) => row.mapControl.uniqueStrategicControlSeconds)),
315
+ contestedFrontierControlSeconds: sumNullable(rows.map((row) => row.mapControl.contestedFrontierControlSeconds)),
316
+ routeDenialSeconds: sumNullable(rows.map((row) => row.mapControl.routeDenialSeconds)),
317
+ teammateAdvanceUnits: sumNullable(rows.map((row) => row.mapControl.teammateAdvanceUnits)),
318
+ firstControlEvents: sumNullable(rows.map((row) => row.mapControl.firstControlEvents))
216
319
  },
217
320
  clutch: {
218
321
  vsOne: sumSplit(rows, (row) => row.clutch.vsOne),
@@ -228,8 +331,13 @@ function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): A
228
331
  },
229
332
  utility: {
230
333
  flashAssists: sum(rows, (row) => row.utility.flashAssists),
231
- enemyFlashDurationSeconds: sum(rows, (row) => row.utility.enemyFlashDurationSeconds),
232
- teamFlashDurationSeconds: sumNullable(rows.map((row) => row.utility.teamFlashDurationSeconds)),
334
+ effectiveEnemyFlashSeconds: sumNullable(rows.map((row) => row.utility.effectiveEnemyFlashSeconds)),
335
+ teamFlashSuppressionSeconds: sumNullable(rows.map((row) => row.utility.teamFlashSuppressionSeconds)),
336
+ smokeProtectedCrossings: sumNullable(rows.map((row) => row.utility.smokeProtectedCrossings)),
337
+ smokeSightlineDenialSeconds: sumNullable(rows.map((row) => row.utility.smokeSightlineDenialSeconds)),
338
+ smokeIsolationSeconds: sumNullable(rows.map((row) => row.utility.smokeIsolationSeconds)),
339
+ incendiaryPathDelayUnits: sumNullable(rows.map((row) => row.utility.incendiaryPathDelayUnits)),
340
+ incendiaryDisplacementEvents: sumNullable(rows.map((row) => row.utility.incendiaryDisplacementEvents)),
233
341
  utilityDamage: sum(rows, (row) => row.utility.utilityDamage)
234
342
  }
235
343
  };
@@ -333,15 +441,48 @@ function aggregateRRIndicators(steamId64: string, rows: RRIndicators[]): RRIndic
333
441
  };
334
442
  }
335
443
 
336
- // 账户平衡的数学(标准化 + 残差化)已迁入 rival-rating 的 computeCohortAccountsRR(公式归属)。
337
- // 本层只负责把 std(rrV1) 作为 targetStd 传进去,对齐 v2 与 HLTV 逆向的量纲。
338
- function stdev(xs: number[]): number {
339
- if (xs.length === 0) return 0;
340
- const m = sum(xs, (x) => x) / xs.length;
341
- return Math.sqrt(sum(xs, (x) => (x - m) ** 2) / xs.length);
444
+ function aggregateWeaponHighlights(
445
+ steamId64: string,
446
+ rows: PlayerWeaponHighlightFacts[]
447
+ ): PlayerWeaponHighlightFacts {
448
+ const weapons = new Map<string, PlayerWeaponHighlightFacts["weapons"][number]>();
449
+ for (const row of rows) {
450
+ for (const weapon of row.weapons) {
451
+ const current = weapons.get(weapon.weapon) ?? {
452
+ weapon: weapon.weapon,
453
+ kills: 0,
454
+ headshotKills: 0,
455
+ tradeKills: 0,
456
+ noScopeKills: 0,
457
+ throughSmokeKills: 0,
458
+ wallbangKills: 0,
459
+ penetratedObjects: 0
460
+ };
461
+ current.kills += weapon.kills;
462
+ current.headshotKills += weapon.headshotKills;
463
+ current.tradeKills += weapon.tradeKills;
464
+ current.noScopeKills += weapon.noScopeKills;
465
+ current.throughSmokeKills += weapon.throughSmokeKills;
466
+ current.wallbangKills += weapon.wallbangKills;
467
+ current.penetratedObjects += weapon.penetratedObjects;
468
+ weapons.set(weapon.weapon, current);
469
+ }
470
+ }
471
+ return {
472
+ steamId64,
473
+ totalKills: sum(rows, (row) => row.totalKills),
474
+ weapons: [...weapons.values()]
475
+ .sort((a, b) => b.kills - a.kills || a.weapon.localeCompare(b.weapon)),
476
+ highlights: {
477
+ wallbangKills: sumNullable(rows.map((row) => row.highlights.wallbangKills)),
478
+ noScopeKills: sumNullable(rows.map((row) => row.highlights.noScopeKills)),
479
+ throughSmokeKills: sumNullable(rows.map((row) => row.highlights.throughSmokeKills)),
480
+ collateralKills: sumNullable(rows.map((row) => row.highlights.collateralKills))
481
+ }
482
+ };
342
483
  }
343
484
 
344
- function accountContextStatus(rows: AccountSignalsV2[]): { buyDelta: AccountContextAvailability; manState: AccountContextAvailability } {
485
+ function accountContextStatus(rows: RRSignals[]): { buyDelta: AccountContextAvailability; manState: AccountContextAvailability } {
345
486
  return {
346
487
  buyDelta: availability(rows.map((row) => row.combat.killsByBuyDelta != null)),
347
488
  manState: availability(rows.map((row) => row.combat.killsByManState != null))
@@ -367,7 +508,7 @@ function availabilityScore(value: AccountContextAvailability): number {
367
508
  return 0;
368
509
  }
369
510
 
370
- function sumBuyDelta(values: AccountSignalsV2["combat"]["killsByBuyDelta"][]): AccountSignalsV2["combat"]["killsByBuyDelta"] {
511
+ function sumBuyDelta(values: RRSignals["combat"]["killsByBuyDelta"][]): RRSignals["combat"]["killsByBuyDelta"] {
371
512
  const present = values.filter((value): value is NonNullable<typeof value> => value != null);
372
513
  if (present.length === 0) return null;
373
514
  return {
@@ -377,7 +518,7 @@ function sumBuyDelta(values: AccountSignalsV2["combat"]["killsByBuyDelta"][]): A
377
518
  };
378
519
  }
379
520
 
380
- function sumManState(values: AccountSignalsV2["combat"]["killsByManState"][]): AccountSignalsV2["combat"]["killsByManState"] {
521
+ function sumManState(values: RRSignals["combat"]["killsByManState"][]): RRSignals["combat"]["killsByManState"] {
381
522
  const present = values.filter((value): value is NonNullable<typeof value> => value != null);
382
523
  if (present.length === 0) return null;
383
524
  return {