@cs2dak/cohort 1.0.0 → 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": "1.0.0",
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": "1.0.0",
12
- "@cs2dak/core": "1.0.0"
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,33 +1,77 @@
1
1
  import {
2
- deriveAccountSignalsV2,
2
+ deriveRRSignals,
3
3
  derivePlayerWeaponHighlights,
4
- deriveRRIndicators,
5
- computeAccountRatingsV2
4
+ deriveRRIndicators
6
5
  } from "@cs2dak/core";
7
6
  import {
8
7
  seasonCohortBundleSchema,
9
8
  type AccountContextAvailability,
10
- type AccountSignalsV2,
11
9
  type DemoPackage,
12
10
  type RRIndicators,
11
+ type RRSignals,
13
12
  type PlayerWeaponHighlightFacts,
14
13
  type SeasonCohortBundle,
15
14
  type TeamKey,
16
- type ValueAccountsWeights
15
+ type RRSixAccountWeights
17
16
  } from "@cs2dak/contract";
18
17
  import {
19
- computeCohortAccountsRR,
18
+ computeFrozenProBaselineRR,
20
19
  computePrism,
21
20
  computeRR,
22
21
  prismWeightsV1,
22
+ rrSixAccountProBaselineV0,
23
23
  rrToPercentile,
24
- rrValueAccountsV2Lite,
25
- rrWeightsV1,
24
+ hltv2BaselineWeightsV1,
25
+ rrSixAccountWeightsV1,
26
+ type ProBaselineConfig,
26
27
  type PrismComputeInput,
27
28
  type PrismWeights,
28
29
  type RRWeights
29
30
  } from "@rivalhub/rival-rating";
30
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
+ */
31
75
  export interface SeasonCohortInput {
32
76
  matchId: string;
33
77
  pkg: DemoPackage;
@@ -35,11 +79,22 @@ export interface SeasonCohortInput {
35
79
 
36
80
  export interface SeasonCohortOptions {
37
81
  rrWeights?: RRWeights;
38
- valueWeights?: ValueAccountsWeights;
82
+ valueWeights?: RRSixAccountWeights;
39
83
  prismWeights?: PrismWeights;
40
84
  identityMap?: PlayerIdentityMap;
41
85
  }
42
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
+
43
98
  export interface PlayerIdentity {
44
99
  playerKey: string;
45
100
  displayName?: string;
@@ -57,7 +112,7 @@ interface PlayerAccumulator {
57
112
  names: Map<string, number>;
58
113
  teamKeys: Set<TeamKey>;
59
114
  mapCount: number;
60
- signals: AccountSignalsV2[];
115
+ signals: RRSignals[];
61
116
  indicators: RRIndicators[];
62
117
  weaponHighlights: PlayerWeaponHighlightFacts[];
63
118
  perMatch: Array<{ matchId: string; steamId64: string; accountRR: number; rrV1: number }>;
@@ -67,59 +122,87 @@ export function buildSeasonCohort(
67
122
  demos: SeasonCohortInput[],
68
123
  opts: SeasonCohortOptions = {}
69
124
  ): SeasonCohortBundle {
70
- const rrWeights = opts.rrWeights ?? (rrWeightsV1 as unknown as RRWeights);
71
- const valueWeights = opts.valueWeights ?? (rrValueAccountsV2Lite as unknown as ValueAccountsWeights);
72
- const prismWeights = opts.prismWeights ?? (prismWeightsV1 as unknown as PrismWeights);
73
- const identityMap = opts.identityMap ?? {};
74
- const players = new Map<string, PlayerAccumulator>();
75
-
125
+ const rrWeights = opts.rrWeights ?? (hltv2BaselineWeightsV1 as unknown as RRWeights);
126
+ const valueWeights = opts.valueWeights ?? (rrSixAccountWeightsV1 as unknown as RRSixAccountWeights);
127
+ const rows: SeasonCohortFactRow[] = [];
76
128
  for (const demo of demos) {
77
- const signals = deriveAccountSignalsV2(demo.pkg);
129
+ const signals = deriveRRSignals(demo.pkg);
78
130
  const indicators = deriveRRIndicators(demo.pkg);
79
131
  const weaponHighlights = derivePlayerWeaponHighlights(demo.pkg);
80
- const matchAccounts = computeAccountRatingsV2(demo.pkg);
81
- const matchAccountBySteamId = new Map(matchAccounts.map((row) => [row.signals.steamId64, row.rr]));
82
- const rrBySteamId = new Map(indicators.map((row) => [row.steamId64, computeRR(row, rrWeights)]));
83
-
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]));
84
135
  for (const player of demo.pkg.players) {
85
- const signal = signals.find((row) => row.steamId64 === player.steamId64);
86
- const indicator = indicators.find((row) => row.steamId64 === player.steamId64);
136
+ const signal = signalBySteamId.get(player.steamId64);
137
+ const indicator = indicatorBySteamId.get(player.steamId64);
87
138
  if (!signal || !indicator) continue;
88
- const identity = resolveIdentity(player.steamId64, identityMap);
89
-
90
- const acc = getOrInit(players, identity.playerKey, () => ({
91
- playerKey: identity.playerKey,
92
- steamIds: new Set<string>(),
93
- primarySteamId64: player.steamId64,
94
- externalUserId: identity.userId ?? null,
95
- displayName: identity.displayName ?? null,
96
- names: new Map<string, number>(),
97
- teamKeys: new Set<TeamKey>(),
98
- mapCount: 0,
99
- signals: [],
100
- indicators: [],
101
- weaponHighlights: [],
102
- perMatch: []
103
- }));
104
-
105
- acc.steamIds.add(player.steamId64);
106
- if (!acc.externalUserId && identity.userId) acc.externalUserId = identity.userId;
107
- if (!acc.displayName && identity.displayName) acc.displayName = identity.displayName;
108
- acc.names.set(player.name, (acc.names.get(player.name) ?? 0) + 1);
109
- acc.teamKeys.add(player.teamKey);
110
- acc.mapCount += 1;
111
- acc.signals.push(signal);
112
- acc.indicators.push(indicator);
113
- const weaponHighlight = weaponHighlights.find((row) => row.steamId64 === player.steamId64);
114
- if (weaponHighlight) acc.weaponHighlights.push(weaponHighlight);
115
- acc.perMatch.push({
139
+ rows.push({
116
140
  matchId: demo.matchId,
141
+ sourceDemoHash: demo.pkg.manifest.demo?.hash ?? null,
117
142
  steamId64: player.steamId64,
118
- accountRR: round(matchAccountBySteamId.get(player.steamId64)?.rr ?? 0, 3),
119
- 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
120
148
  });
121
149
  }
122
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
+ }
123
206
 
124
207
  const seasonRows = [...players.values()].map((acc) => {
125
208
  const signals = aggregateAccountSignals(acc.playerKey, acc.signals);
@@ -128,15 +211,11 @@ export function buildSeasonCohort(
128
211
  return { acc, signals, indicators, rrV1 };
129
212
  });
130
213
 
131
- // 账户平衡(标准化 + 残差化)由 rival-rating 的 computeCohortAccountsRR 拥有(公式归属)。
132
- // scale 对齐到 rrV1 的离散度,使 v2 与已被 HLTV 逆向验证的 v1 量纲可比。详见该库 docs/rr-v2.md
133
- // 与本仓库 docs/design/cohort.md。
134
- const targetStd = stdev(seasonRows.map((row) => row.rrV1.rr));
135
214
  const balancedByKey = new Map(
136
- computeCohortAccountsRR(seasonRows.map((row) => row.signals), valueWeights, { targetStd }).map((b) => [
137
- b.steamId64,
138
- b
139
- ])
215
+ seasonRows.map((row) => {
216
+ const scored = computeFrozenProBaselineRR(row.signals, valueWeights, proBaseline);
217
+ return [scored.steamId64, scored] as const;
218
+ })
140
219
  );
141
220
  const rrV1Scores = seasonRows.map((row) => row.rrV1.rr);
142
221
  const prismInputs: PrismComputeInput[] = seasonRows.map((row) => ({
@@ -148,15 +227,15 @@ export function buildSeasonCohort(
148
227
 
149
228
  return seasonCohortBundleSchema.parse({
150
229
  version: "cs2-demo-analysis-kit/cohort-1.0",
151
- matchCount: demos.length,
230
+ matchCount: opts.matchCount ?? new Set(rows.map((row) => row.matchId)).size,
152
231
  weightsVersion: `${rrWeights.version}+${valueWeights.version}+${prismWeights.version}`,
153
232
  provenance: {
154
233
  cohortVersion: "cs2-demo-analysis-kit/cohort-1.0",
155
- sourceSchemaVersion: "cs2-demo-format/2.0",
156
- matches: demos.map((demo) => ({
157
- matchId: demo.matchId,
158
- sourceDemoHash: demo.pkg.manifest.demo?.hash ?? null
159
- }))
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()]
160
239
  },
161
240
  players: seasonRows
162
241
  .map((row) => {
@@ -180,6 +259,7 @@ export function buildSeasonCohort(
180
259
  accountBreakdown: {
181
260
  combat: round(balanced.accounts.combat, 4),
182
261
  trade: round(balanced.accounts.trade, 4),
262
+ mapControl: round(balanced.accounts.mapControl, 4),
183
263
  clutch: round(balanced.accounts.clutch, 4),
184
264
  objective: round(balanced.accounts.objective, 4),
185
265
  utility: round(balanced.accounts.utility, 4)
@@ -200,7 +280,7 @@ function resolveIdentity(steamId64: string, identityMap: PlayerIdentityMap): Pla
200
280
  return value;
201
281
  }
202
282
 
203
- function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): AccountSignalsV2 {
283
+ function aggregateAccountSignals(steamId64: string, rows: RRSignals[]): RRSignals {
204
284
  return {
205
285
  steamId64,
206
286
  rounds: sum(rows, (row) => row.rounds),
@@ -227,7 +307,15 @@ function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): A
227
307
  tradeKills: sum(rows, (row) => row.trade.tradeKills),
228
308
  tradedDeaths: sum(rows, (row) => row.trade.tradedDeaths),
229
309
  deaths: sum(rows, (row) => row.trade.deaths),
230
- 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))
231
319
  },
232
320
  clutch: {
233
321
  vsOne: sumSplit(rows, (row) => row.clutch.vsOne),
@@ -243,8 +331,13 @@ function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): A
243
331
  },
244
332
  utility: {
245
333
  flashAssists: sum(rows, (row) => row.utility.flashAssists),
246
- enemyFlashDurationSeconds: sum(rows, (row) => row.utility.enemyFlashDurationSeconds),
247
- 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)),
248
341
  utilityDamage: sum(rows, (row) => row.utility.utilityDamage)
249
342
  }
250
343
  };
@@ -389,15 +482,7 @@ function aggregateWeaponHighlights(
389
482
  };
390
483
  }
391
484
 
392
- // 账户平衡的数学(标准化 + 残差化)已迁入 rival-rating computeCohortAccountsRR(公式归属)。
393
- // 本层只负责把 std(rrV1) 作为 targetStd 传进去,对齐 v2 与 HLTV 逆向的量纲。
394
- function stdev(xs: number[]): number {
395
- if (xs.length === 0) return 0;
396
- const m = sum(xs, (x) => x) / xs.length;
397
- return Math.sqrt(sum(xs, (x) => (x - m) ** 2) / xs.length);
398
- }
399
-
400
- function accountContextStatus(rows: AccountSignalsV2[]): { buyDelta: AccountContextAvailability; manState: AccountContextAvailability } {
485
+ function accountContextStatus(rows: RRSignals[]): { buyDelta: AccountContextAvailability; manState: AccountContextAvailability } {
401
486
  return {
402
487
  buyDelta: availability(rows.map((row) => row.combat.killsByBuyDelta != null)),
403
488
  manState: availability(rows.map((row) => row.combat.killsByManState != null))
@@ -423,7 +508,7 @@ function availabilityScore(value: AccountContextAvailability): number {
423
508
  return 0;
424
509
  }
425
510
 
426
- function sumBuyDelta(values: AccountSignalsV2["combat"]["killsByBuyDelta"][]): AccountSignalsV2["combat"]["killsByBuyDelta"] {
511
+ function sumBuyDelta(values: RRSignals["combat"]["killsByBuyDelta"][]): RRSignals["combat"]["killsByBuyDelta"] {
427
512
  const present = values.filter((value): value is NonNullable<typeof value> => value != null);
428
513
  if (present.length === 0) return null;
429
514
  return {
@@ -433,7 +518,7 @@ function sumBuyDelta(values: AccountSignalsV2["combat"]["killsByBuyDelta"][]): A
433
518
  };
434
519
  }
435
520
 
436
- function sumManState(values: AccountSignalsV2["combat"]["killsByManState"][]): AccountSignalsV2["combat"]["killsByManState"] {
521
+ function sumManState(values: RRSignals["combat"]["killsByManState"][]): RRSignals["combat"]["killsByManState"] {
437
522
  const present = values.filter((value): value is NonNullable<typeof value> => value != null);
438
523
  if (present.length === 0) return null;
439
524
  return {
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PlayerPositionRoundFact, TeamShapeRoundFact } from "@cs2dak/contract";
3
+ import { buildPlayerMapRoleEvidence, buildTeamMapResponsibilityEvidence } from "./index.js";
4
+
5
+ function row(playerIndex: number, roundNumber: number, overrides: Partial<PlayerPositionRoundFact> = {}): PlayerPositionRoundFact {
6
+ return {
7
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber, teamKey: "teamA", side: "ct",
8
+ playerIndex, steamId64: `7656119800000000${playerIndex + 1}`, eligibleSeconds: 20,
9
+ economyType: "full", openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
10
+ openingEligibleSeconds: 20, openingPositionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_anchor" : "b_anchor", seconds: 16, share: 0.8 }],
11
+ openingMeanComponentSize: 3, openingIsolationSeconds: 0, openingUtilityUseCount: 0,
12
+ openingPath: [],
13
+ positionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_anchor" : "b_anchor", seconds: 16, share: 0.8 }],
14
+ unresolvedCalloutSeconds: 0, calloutCoverage: 1, meanNearestTeammateDistance: 200, meanTeamCentroidDistance: 300,
15
+ meanComponentSize: 3, isolationSegments: [], rejoinTicks: [], delayedConvergences: [], movementSync: 0.6, utilityUseCount: 0,
16
+ freezeAwpOwnership: playerIndex === 0, activeAwpSeconds: playerIndex === 0 ? 12 : 0, awpShots: playerIndex === 0 ? 2 : 0, awpKills: playerIndex === 0 ? 1 : 0,
17
+ availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("map role evidence", () => {
23
+ it("merges identities, computes team-relative AWP duty, and keeps unsupported maps out", () => {
24
+ const rows = Array.from({ length: 6 }, (_, index) => [row(0, index + 1), row(1, index + 1)]).flat();
25
+ rows.push(row(2, 1, { mapName: "de_vertigo" }));
26
+ const options = {
27
+ identityMap: { "76561198000000001": { playerKey: "player:one" } },
28
+ teamIdentityMap: { "m1:teamA": "Team One" },
29
+ };
30
+ const players = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, options);
31
+ const awper = players.find((player) => player.playerKey === "player:one")!;
32
+ expect(players).toHaveLength(2);
33
+ expect(awper.teamKey).toBe("Team One");
34
+ expect(awper.awp.duty).toBe("secondary_awper");
35
+ expect(awper.spatial.teamRelativeGroupShare).toBeGreaterThan(0);
36
+
37
+ const teams = buildTeamMapResponsibilityEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, options);
38
+ expect(teams).toHaveLength(1);
39
+ expect(teams[0]?.players).toHaveLength(2);
40
+ });
41
+
42
+ it("keeps missing replay data unknown and null instead of making a zero-valued role", () => {
43
+ const missing = row(0, 1, {
44
+ eligibleSeconds: null, positionGroupDwell: [], activeAwpSeconds: null, awpShots: null, awpKills: null,
45
+ availability: { replay: "missing", nav: "missing", callouts: "missing", shots: "missing" },
46
+ });
47
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: [missing], teamShapeRounds: [] });
48
+ expect(evidence[0]).toMatchObject({ status: "unknown", awp: { activeSeconds: null, teamActiveShare: null } });
49
+ });
50
+
51
+ it("consumes opening component membership and formation continuity from TeamShapeRoundFact", () => {
52
+ const rows = Array.from({ length: 6 }, (_, index) => [row(0, index + 1), row(4, index + 1)]).flat();
53
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
54
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "ct",
55
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
56
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [4, 1], partition: "4+1", componentPlayerIndices: [[0, 1, 2, 3], [4]] }],
57
+ coverageSeconds: 40, windows: [{ startTick: 100, endTick: 2660, coverageSeconds: 40, componentSizes: [4, 1], partition: "4+1", componentPlayerIndices: [[0, 1, 2, 3], [4]] }],
58
+ availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
59
+ }));
60
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: shapes });
61
+ expect(evidence.find((item) => item.playerKey.endsWith("1"))?.spatial).toMatchObject({ openingMainComponentShare: 1, formationShares: { "4+1": 1 } });
62
+ expect(evidence.find((item) => item.playerKey.endsWith("5"))?.spatial.openingIsolatedShare).toBe(1);
63
+ });
64
+
65
+ it("does not assign main-component credit when the opening has no unique majority core", () => {
66
+ const rows = Array.from({ length: 6 }, (_, index) => row(0, index + 1));
67
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
68
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "ct",
69
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
70
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [2, 2, 1], partition: "2+2+1", componentPlayerIndices: [[0, 1], [2, 3], [4]] }],
71
+ coverageSeconds: 20, windows: [], availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
72
+ }));
73
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: shapes })[0]!;
74
+ expect(evidence.spatial.openingMainComponentShare).toBe(0);
75
+ expect(evidence.spatial.openingNoUniqueCoreShare).toBe(1);
76
+ });
77
+
78
+ it("aggregates team matrices to one row per unique player before overlap", () => {
79
+ const sameGroup = { openingPositionGroupDwell: [{ positionGroupId: "a_anchor", seconds: 16, share: 0.8 }], positionGroupDwell: [{ positionGroupId: "a_anchor", seconds: 16, share: 0.8 }] };
80
+ const rows = Array.from({ length: 6 }, (_, index) => [
81
+ row(0, index + 1, sameGroup), row(1, index + 1, sameGroup),
82
+ row(0, index + 1, { ...sameGroup, matchId: "m2" }), row(1, index + 1, { ...sameGroup, matchId: "m2" }),
83
+ ]).flat();
84
+ const teams = buildTeamMapResponsibilityEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, { teamIdentityMap: { "m1:teamA": "Team One", "m2:teamA": "Team One" } });
85
+ expect(teams[0]?.players).toHaveLength(2);
86
+ expect(teams[0]?.positionOverlap[0]?.playerKeys).toHaveLength(2);
87
+ expect(new Set(teams[0]?.positionOverlap[0]?.playerKeys).size).toBe(2);
88
+ });
89
+
90
+ it("produces support responsibilities only from observable utility timing and team coordination", () => {
91
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
92
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "t",
93
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
94
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [5], partition: "5", componentPlayerIndices: [[0, 1, 2, 3, 4]] }],
95
+ coverageSeconds: 20, windows: [], availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
96
+ }));
97
+ const supported = Array.from({ length: 6 }, (_, index) => row(0, index + 1, { side: "t", utilityUseCount: 1, openingUtilityUseCount: 1 }));
98
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported, teamShapeRounds: shapes })[0]?.modifiers).toContain("utility_supportive");
99
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported.map((item) => ({ ...item, utilityUseCount: 0, openingUtilityUseCount: 0 })), teamShapeRounds: shapes })[0]?.modifiers).not.toContain("utility_supportive");
100
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported.map((item) => ({ ...item, side: "ct" })), teamShapeRounds: shapes })[0]?.modifiers).toContain("utility_supportive");
101
+ });
102
+ });