@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 +7 -0
- package/package.json +5 -5
- package/src/index.test.ts +150 -0
- package/src/index.ts +167 -82
- 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": "
|
|
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": "1.
|
|
12
|
-
"@cs2dak/core": "
|
|
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,33 +1,77 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
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
|
|
15
|
+
type RRSixAccountWeights
|
|
17
16
|
} from "@cs2dak/contract";
|
|
18
17
|
import {
|
|
19
|
-
|
|
18
|
+
computeFrozenProBaselineRR,
|
|
20
19
|
computePrism,
|
|
21
20
|
computeRR,
|
|
22
21
|
prismWeightsV1,
|
|
22
|
+
rrSixAccountProBaselineV0,
|
|
23
23
|
rrToPercentile,
|
|
24
|
-
|
|
25
|
-
|
|
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?:
|
|
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:
|
|
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 ?? (
|
|
71
|
-
const valueWeights = opts.valueWeights ?? (
|
|
72
|
-
const
|
|
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 =
|
|
129
|
+
const signals = deriveRRSignals(demo.pkg);
|
|
78
130
|
const indicators = deriveRRIndicators(demo.pkg);
|
|
79
131
|
const weaponHighlights = derivePlayerWeaponHighlights(demo.pkg);
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
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 =
|
|
86
|
-
const indicator =
|
|
136
|
+
const signal = signalBySteamId.get(player.steamId64);
|
|
137
|
+
const indicator = indicatorBySteamId.get(player.steamId64);
|
|
87
138
|
if (!signal || !indicator) continue;
|
|
88
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
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/
|
|
156
|
-
matches:
|
|
157
|
-
matchId:
|
|
158
|
-
sourceDemoHash:
|
|
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:
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
});
|