@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 +7 -0
- package/package.json +5 -5
- package/src/index.test.ts +150 -0
- package/src/index.ts +216 -75
- package/src/map-role-evidence.test.ts +102 -0
- package/src/map-role-evidence.ts +358 -0
- package/src/patterns.test.ts +136 -0
- package/src/patterns.ts +117 -0
- package/src/t-role-research-projection.test.ts +144 -0
- package/src/t-role-research-projection.ts +241 -0
- package/src/tactical-clusters.ts +223 -0
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.
|
|
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.
|
|
11
|
-
"@cs2dak/contract": "
|
|
12
|
-
"@cs2dak/core": "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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
15
|
+
type RRSixAccountWeights
|
|
15
16
|
} from "@cs2dak/contract";
|
|
16
17
|
import {
|
|
17
|
-
|
|
18
|
+
computeFrozenProBaselineRR,
|
|
18
19
|
computePrism,
|
|
19
20
|
computeRR,
|
|
20
21
|
prismWeightsV1,
|
|
22
|
+
rrSixAccountProBaselineV0,
|
|
21
23
|
rrToPercentile,
|
|
22
|
-
|
|
23
|
-
|
|
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?:
|
|
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:
|
|
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 ?? (
|
|
68
|
-
const valueWeights = opts.valueWeights ?? (
|
|
69
|
-
const
|
|
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 =
|
|
129
|
+
const signals = deriveRRSignals(demo.pkg);
|
|
75
130
|
const indicators = deriveRRIndicators(demo.pkg);
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
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 =
|
|
82
|
-
const indicator =
|
|
136
|
+
const signal = signalBySteamId.get(player.steamId64);
|
|
137
|
+
const indicator = indicatorBySteamId.get(player.steamId64);
|
|
83
138
|
if (!signal || !indicator) continue;
|
|
84
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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/
|
|
144
|
-
matchCount:
|
|
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:
|
|
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/
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|