@cs2dak/cohort 0.2.1

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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +20 -0
  3. package/src/index.ts +435 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Starfie1d
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@cs2dak/cohort",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "dependencies": {
10
+ "@rivalhub/rival-rating": "^0.1.0",
11
+ "@cs2dak/contract": "0.2.1",
12
+ "@cs2dak/core": "0.2.1"
13
+ },
14
+ "files": [
15
+ "src/index.ts"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ }
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,435 @@
1
+ import {
2
+ deriveAccountSignalsV2,
3
+ deriveRRIndicators,
4
+ computeAccountRatingsV2
5
+ } from "@cs2dak/core";
6
+ import {
7
+ seasonCohortBundleSchema,
8
+ type AccountContextAvailability,
9
+ type AccountSignalsV2,
10
+ type DemoPackage,
11
+ type RRIndicators,
12
+ type SeasonCohortBundle,
13
+ type TeamKey,
14
+ type ValueAccountsWeights
15
+ } from "@cs2dak/contract";
16
+ import {
17
+ computeCohortAccountsRR,
18
+ computePrism,
19
+ computeRR,
20
+ prismWeightsV1,
21
+ rrToPercentile,
22
+ rrValueAccountsV2Lite,
23
+ rrWeightsV1,
24
+ type PrismComputeInput,
25
+ type PrismWeights,
26
+ type RRWeights
27
+ } from "@rivalhub/rival-rating";
28
+
29
+ export interface SeasonCohortInput {
30
+ matchId: string;
31
+ pkg: DemoPackage;
32
+ }
33
+
34
+ export interface SeasonCohortOptions {
35
+ rrWeights?: RRWeights;
36
+ valueWeights?: ValueAccountsWeights;
37
+ prismWeights?: PrismWeights;
38
+ identityMap?: PlayerIdentityMap;
39
+ }
40
+
41
+ export interface PlayerIdentity {
42
+ playerKey: string;
43
+ displayName?: string;
44
+ userId?: string;
45
+ }
46
+
47
+ export type PlayerIdentityMap = Record<string, string | PlayerIdentity>;
48
+
49
+ interface PlayerAccumulator {
50
+ playerKey: string;
51
+ steamIds: Set<string>;
52
+ primarySteamId64: string;
53
+ externalUserId: string | null;
54
+ displayName: string | null;
55
+ names: Map<string, number>;
56
+ teamKeys: Set<TeamKey>;
57
+ mapCount: number;
58
+ signals: AccountSignalsV2[];
59
+ indicators: RRIndicators[];
60
+ perMatch: Array<{ matchId: string; steamId64: string; accountRR: number; rrV1: number }>;
61
+ }
62
+
63
+ export function buildSeasonCohort(
64
+ demos: SeasonCohortInput[],
65
+ opts: SeasonCohortOptions = {}
66
+ ): SeasonCohortBundle {
67
+ const rrWeights = opts.rrWeights ?? (rrWeightsV1 as unknown as RRWeights);
68
+ const valueWeights = opts.valueWeights ?? (rrValueAccountsV2Lite as unknown as ValueAccountsWeights);
69
+ const prismWeights = opts.prismWeights ?? (prismWeightsV1 as unknown as PrismWeights);
70
+ const identityMap = opts.identityMap ?? {};
71
+ const players = new Map<string, PlayerAccumulator>();
72
+
73
+ for (const demo of demos) {
74
+ const signals = deriveAccountSignalsV2(demo.pkg);
75
+ const indicators = deriveRRIndicators(demo.pkg);
76
+ const matchAccounts = computeAccountRatingsV2(demo.pkg);
77
+ const matchAccountBySteamId = new Map(matchAccounts.map((row) => [row.signals.steamId64, row.rr]));
78
+ const rrBySteamId = new Map(indicators.map((row) => [row.steamId64, computeRR(row, rrWeights)]));
79
+
80
+ for (const player of demo.pkg.players) {
81
+ const signal = signals.find((row) => row.steamId64 === player.steamId64);
82
+ const indicator = indicators.find((row) => row.steamId64 === player.steamId64);
83
+ if (!signal || !indicator) continue;
84
+ const identity = resolveIdentity(player.steamId64, identityMap);
85
+
86
+ const acc = getOrInit(players, identity.playerKey, () => ({
87
+ playerKey: identity.playerKey,
88
+ steamIds: new Set<string>(),
89
+ primarySteamId64: player.steamId64,
90
+ externalUserId: identity.userId ?? null,
91
+ displayName: identity.displayName ?? null,
92
+ names: new Map<string, number>(),
93
+ teamKeys: new Set<TeamKey>(),
94
+ mapCount: 0,
95
+ signals: [],
96
+ indicators: [],
97
+ perMatch: []
98
+ }));
99
+
100
+ acc.steamIds.add(player.steamId64);
101
+ if (!acc.externalUserId && identity.userId) acc.externalUserId = identity.userId;
102
+ if (!acc.displayName && identity.displayName) acc.displayName = identity.displayName;
103
+ acc.names.set(player.name, (acc.names.get(player.name) ?? 0) + 1);
104
+ acc.teamKeys.add(player.teamKey);
105
+ acc.mapCount += 1;
106
+ acc.signals.push(signal);
107
+ acc.indicators.push(indicator);
108
+ acc.perMatch.push({
109
+ matchId: demo.matchId,
110
+ steamId64: player.steamId64,
111
+ accountRR: round(matchAccountBySteamId.get(player.steamId64)?.rr ?? 0, 3),
112
+ rrV1: round(rrBySteamId.get(player.steamId64)?.rr ?? 0, 3)
113
+ });
114
+ }
115
+ }
116
+
117
+ const seasonRows = [...players.values()].map((acc) => {
118
+ const signals = aggregateAccountSignals(acc.playerKey, acc.signals);
119
+ const indicators = aggregateRRIndicators(acc.playerKey, acc.indicators);
120
+ const rrV1 = computeRR(indicators, rrWeights);
121
+ return { acc, signals, indicators, rrV1 };
122
+ });
123
+
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
+ const balancedByKey = new Map(
129
+ computeCohortAccountsRR(seasonRows.map((row) => row.signals), valueWeights, { targetStd }).map((b) => [
130
+ b.steamId64,
131
+ b
132
+ ])
133
+ );
134
+ const rrV1Scores = seasonRows.map((row) => row.rrV1.rr);
135
+ const prismInputs: PrismComputeInput[] = seasonRows.map((row) => ({
136
+ indicators: row.indicators,
137
+ mapCount: row.acc.mapCount,
138
+ rrPercentile: rrToPercentile(rrV1Scores, row.rrV1.rr)
139
+ }));
140
+ const prismResults = new Map(computePrism(prismInputs, prismWeights).map((row) => [row.steamId64, row]));
141
+
142
+ return seasonCohortBundleSchema.parse({
143
+ version: "cs2-demo-analysis-kit/season-0.1",
144
+ matchCount: demos.length,
145
+ weightsVersion: `${rrWeights.version}+${valueWeights.version}+${prismWeights.version}`,
146
+ players: seasonRows
147
+ .map((row) => {
148
+ const balanced = balancedByKey.get(row.acc.playerKey)!;
149
+ const contextStatus = accountContextStatus(row.acc.signals);
150
+ const steamIds = [...row.acc.steamIds].sort();
151
+ return {
152
+ playerKey: row.acc.playerKey,
153
+ steamIds,
154
+ primarySteamId64: row.acc.primarySteamId64,
155
+ externalUserId: row.acc.externalUserId,
156
+ name: row.acc.displayName ?? mostCommonName(row.acc.names),
157
+ teamKeys: [...row.acc.teamKeys],
158
+ mapCount: row.acc.mapCount,
159
+ rrV1: round(row.rrV1.rr, 3),
160
+ rrV1Percentile: round(rrToPercentile(rrV1Scores, row.rrV1.rr), 1),
161
+ indicators: row.indicators,
162
+ accountRR: round(balanced.rr, 3),
163
+ accountRRRaw: round(balanced.rrRaw, 3),
164
+ accountBreakdown: {
165
+ combat: round(balanced.accounts.combat, 4),
166
+ trade: round(balanced.accounts.trade, 4),
167
+ clutch: round(balanced.accounts.clutch, 4),
168
+ objective: round(balanced.accounts.objective, 4),
169
+ utility: round(balanced.accounts.utility, 4)
170
+ },
171
+ accountContextStatus: contextStatus,
172
+ prism: prismResults.get(row.acc.playerKey) ?? null,
173
+ confidence: confidence(row.acc.mapCount, contextStatus),
174
+ perMatch: row.acc.perMatch.sort((a, b) => a.matchId.localeCompare(b.matchId))
175
+ };
176
+ })
177
+ .sort((a, b) => b.accountRR - a.accountRR || b.rrV1 - a.rrV1 || a.name.localeCompare(b.name))
178
+ });
179
+ }
180
+
181
+ function resolveIdentity(steamId64: string, identityMap: PlayerIdentityMap): PlayerIdentity {
182
+ const value = identityMap[steamId64];
183
+ if (!value) return { playerKey: `steam:${steamId64}` };
184
+ if (typeof value === "string") return { playerKey: value };
185
+ return value;
186
+ }
187
+
188
+ function aggregateAccountSignals(steamId64: string, rows: AccountSignalsV2[]): AccountSignalsV2 {
189
+ return {
190
+ steamId64,
191
+ rounds: sum(rows, (row) => row.rounds),
192
+ sourceVersion: "cs2-demo-analysis-kit/season-0.1",
193
+ combat: {
194
+ kills: sum(rows, (row) => row.combat.kills),
195
+ deaths: sum(rows, (row) => row.combat.deaths),
196
+ assists: sum(rows, (row) => row.combat.assists),
197
+ effectiveDamage: sum(rows, (row) => row.combat.effectiveDamage),
198
+ openingKills: sum(rows, (row) => row.combat.openingKills),
199
+ openingDeaths: sum(rows, (row) => row.combat.openingDeaths),
200
+ multiKills: {
201
+ two: sum(rows, (row) => row.combat.multiKills.two),
202
+ three: sum(rows, (row) => row.combat.multiKills.three),
203
+ four: sum(rows, (row) => row.combat.multiKills.four),
204
+ five: sum(rows, (row) => row.combat.multiKills.five)
205
+ },
206
+ headshotKills: sum(rows, (row) => row.combat.headshotKills),
207
+ wallbangKills: sumNullable(rows.map((row) => row.combat.wallbangKills)),
208
+ killsByBuyDelta: sumBuyDelta(rows.map((row) => row.combat.killsByBuyDelta)),
209
+ killsByManState: sumManState(rows.map((row) => row.combat.killsByManState))
210
+ },
211
+ trade: {
212
+ tradeKills: sum(rows, (row) => row.trade.tradeKills),
213
+ tradedDeaths: sum(rows, (row) => row.trade.tradedDeaths),
214
+ deaths: sum(rows, (row) => row.trade.deaths),
215
+ tradedOpeningDeaths: sumNullable(rows.map((row) => row.trade.tradedOpeningDeaths))
216
+ },
217
+ clutch: {
218
+ vsOne: sumSplit(rows, (row) => row.clutch.vsOne),
219
+ vsTwo: sumSplit(rows, (row) => row.clutch.vsTwo),
220
+ vsThree: sumSplit(rows, (row) => row.clutch.vsThree),
221
+ vsFour: sumSplit(rows, (row) => row.clutch.vsFour),
222
+ vsFive: sumSplit(rows, (row) => row.clutch.vsFive)
223
+ },
224
+ objective: {
225
+ plants: sum(rows, (row) => row.objective.plants),
226
+ defuses: sum(rows, (row) => row.objective.defuses),
227
+ plantsConverted: sumNullable(rows.map((row) => row.objective.plantsConverted))
228
+ },
229
+ utility: {
230
+ flashAssists: sum(rows, (row) => row.utility.flashAssists),
231
+ enemyFlashDurationSeconds: sum(rows, (row) => row.utility.enemyFlashDurationSeconds),
232
+ teamFlashDurationSeconds: sumNullable(rows.map((row) => row.utility.teamFlashDurationSeconds)),
233
+ utilityDamage: sum(rows, (row) => row.utility.utilityDamage)
234
+ }
235
+ };
236
+ }
237
+
238
+ function aggregateRRIndicators(steamId64: string, rows: RRIndicators[]): RRIndicators {
239
+ const totalRounds = Math.max(sum(rows, (row) => row.totalRounds), 1);
240
+ const kills = sum(rows, (row) => row.kills);
241
+ const deaths = sum(rows, (row) => row.deaths);
242
+ const assists = sum(rows, (row) => row.assists);
243
+ const damage = sum(rows, (row) => row.adr * row.totalRounds);
244
+ const kastRounds = sum(rows, (row) => (row.kast / 100) * row.totalRounds);
245
+ const headshotKills = sum(rows, (row) => (row.hsPercent / 100) * row.kills);
246
+ const tradeDeathCount = sum(rows, (row) => row.tradeDeathCount);
247
+ const firstKillCount = sum(rows, (row) => row.firstKillCount);
248
+ const firstDeathCount = sum(rows, (row) => row.firstDeathCount);
249
+ const openingDuels = firstKillCount + firstDeathCount;
250
+ const clutchAttempts = sum(rows, (row) => row.clutchAttempts);
251
+ const clutchWins = sum(rows, (row) => row.clutchWins);
252
+ const clutchScore = sum(rows, (row) => row.clutchScore);
253
+ const awpKills = sum(rows, (row) => row.awpKills);
254
+ const sniperKills = sum(rows, (row) => row.sniperKills);
255
+ const utilityDamage = sum(rows, (row) => row.utilityDamage);
256
+ const flashAssistCount = sum(rows, (row) => row.flashAssistCount);
257
+ const blindDurationTotal = sum(rows, (row) => row.blindDurationTotal);
258
+ const enemyFlashDurationSeconds = sumNullable(rows.map((row) => row.enemyFlashDurationSeconds));
259
+ const teamFlashDurationSeconds = sumNullable(rows.map((row) => row.teamFlashDurationSeconds));
260
+ const grenadeCount = sum(rows, (row) => row.grenadeCount);
261
+ const combatDeathCount = sumNullable(rows.map((row) => row.combatDeathCount));
262
+ const bombDeathCount = sumNullable(rows.map((row) => row.bombDeathCount));
263
+ const wallbangKillCount = sumNullable(rows.map((row) => row.wallbangKillCount));
264
+
265
+ return {
266
+ steamId64,
267
+ totalRounds,
268
+ kills,
269
+ deaths,
270
+ assists,
271
+ kpr: round(kills / totalRounds, 4),
272
+ dpr: round(deaths / totalRounds, 4),
273
+ apr: round(assists / totalRounds, 4),
274
+ adr: round(damage / totalRounds, 2),
275
+ hsPercent: kills > 0 ? round((headshotKills / kills) * 100, 2) : 0,
276
+ kast: round((kastRounds / totalRounds) * 100, 2),
277
+ survivalRate: round(Math.max(0, totalRounds - deaths) / totalRounds, 4),
278
+ twoKillRounds: sum(rows, (row) => row.twoKillRounds),
279
+ threeKillRounds: sum(rows, (row) => row.threeKillRounds),
280
+ fourKillRounds: sum(rows, (row) => row.fourKillRounds),
281
+ fiveKillRounds: sum(rows, (row) => row.fiveKillRounds),
282
+ multiKillRate: round(sum(rows, (row) => row.twoKillRounds + row.threeKillRounds + row.fourKillRounds + row.fiveKillRounds) / totalRounds, 4),
283
+ firstKillCount,
284
+ firstDeathCount,
285
+ firstKillRate: round(firstKillCount / totalRounds, 4),
286
+ firstDeathRate: round(firstDeathCount / totalRounds, 4),
287
+ openingDuelRate: round(openingDuels / totalRounds, 4),
288
+ openingDuelWinRate: openingDuels > 0 ? round(firstKillCount / openingDuels, 4) : 0,
289
+ tradeKillCount: sum(rows, (row) => row.tradeKillCount),
290
+ tradeDeathCount,
291
+ tradeKillRate: round(sum(rows, (row) => row.tradeKillCount) / totalRounds, 4),
292
+ tradeDeathRate: deaths > 0 ? round(tradeDeathCount / deaths, 4) : 0,
293
+ clutchAttempts,
294
+ clutchWins,
295
+ clutchWinRate: clutchAttempts > 0 ? round(clutchWins / clutchAttempts, 4) : 0,
296
+ clutchFrequency: round(clutchAttempts / totalRounds, 4),
297
+ clutchScore,
298
+ clutchScoreRate: round(clutchScore / totalRounds, 4),
299
+ vsOne: sumSplit(rows, (row) => row.vsOne),
300
+ vsTwo: sumSplit(rows, (row) => row.vsTwo),
301
+ vsThree: sumSplit(rows, (row) => row.vsThree),
302
+ vsFour: sumSplit(rows, (row) => row.vsFour),
303
+ vsFive: sumSplit(rows, (row) => row.vsFive),
304
+ awpKills,
305
+ awpKillsPerRound: round(awpKills / totalRounds, 4),
306
+ awpKillRate: kills > 0 ? round(awpKills / kills, 4) : 0,
307
+ sniperKills,
308
+ sniperKillRate: kills > 0 ? round(sniperKills / kills, 4) : 0,
309
+ awpMultiKillRate: weightedNullableRate(rows, "awpMultiKillRate"),
310
+ awpDuelWinRate: weightedNullableRate(rows, "awpDuelWinRate"),
311
+ utilityDamage,
312
+ utilityDamagePerRound: round(utilityDamage / totalRounds, 2),
313
+ flashAssistCount,
314
+ flashAssistPerRound: round(flashAssistCount / totalRounds, 4),
315
+ blindDurationTotal: round(blindDurationTotal, 2),
316
+ blindDurationPerRound: round(blindDurationTotal / totalRounds, 2),
317
+ enemyFlashDurationSeconds: enemyFlashDurationSeconds == null ? null : round(enemyFlashDurationSeconds, 2),
318
+ enemyFlashDurationPerRound: enemyFlashDurationSeconds == null ? null : round(enemyFlashDurationSeconds / totalRounds, 2),
319
+ teamFlashDurationSeconds: teamFlashDurationSeconds == null ? null : round(teamFlashDurationSeconds, 2),
320
+ teamFlashDurationPerRound: teamFlashDurationSeconds == null ? null : round(teamFlashDurationSeconds / totalRounds, 2),
321
+ grenadeCount,
322
+ grenadeCountPerRound: round(grenadeCount / totalRounds, 4),
323
+ ecoRoundCount: sum(rows, (row) => row.ecoRoundCount),
324
+ forceRoundCount: sum(rows, (row) => row.forceRoundCount),
325
+ fullBuyRoundCount: sum(rows, (row) => row.fullBuyRoundCount),
326
+ pistolRoundCount: sum(rows, (row) => row.pistolRoundCount),
327
+ avgEquipmentValue: weightedAverage(rows.map((row) => ({ value: row.avgEquipmentValue, weight: row.totalRounds }))),
328
+ combatDeathCount,
329
+ bombDeathCount,
330
+ wallbangKillCount,
331
+ roundSwingTotal: sumNullable(rows.map((row) => row.roundSwingTotal)),
332
+ roundSwingPerKill: weightedNullableRate(rows, "roundSwingPerKill")
333
+ };
334
+ }
335
+
336
+ // 账户平衡的数学(标准化 + 残差化)已迁入 rival-rating 的 computeCohortAccountsRR(公式归属)。
337
+ // 本层只负责把 std(rrV1) 作为 targetStd 传进去,对齐 v2 与 HLTV 逆向的量纲。
338
+ function stdev(xs: number[]): number {
339
+ if (xs.length === 0) return 0;
340
+ const m = sum(xs, (x) => x) / xs.length;
341
+ return Math.sqrt(sum(xs, (x) => (x - m) ** 2) / xs.length);
342
+ }
343
+
344
+ function accountContextStatus(rows: AccountSignalsV2[]): { buyDelta: AccountContextAvailability; manState: AccountContextAvailability } {
345
+ return {
346
+ buyDelta: availability(rows.map((row) => row.combat.killsByBuyDelta != null)),
347
+ manState: availability(rows.map((row) => row.combat.killsByManState != null))
348
+ };
349
+ }
350
+
351
+ function confidence(mapCount: number, status: { buyDelta: AccountContextAvailability; manState: AccountContextAvailability }): number {
352
+ const completeness = (availabilityScore(status.buyDelta) + availabilityScore(status.manState)) / 2;
353
+ const sample = mapCount / (mapCount + 3);
354
+ return round(0.7 * completeness + 0.3 * sample, 3);
355
+ }
356
+
357
+ function availability(values: boolean[]): AccountContextAvailability {
358
+ const available = values.filter(Boolean).length;
359
+ if (available === 0) return "missing";
360
+ if (available === values.length) return "available";
361
+ return "partial";
362
+ }
363
+
364
+ function availabilityScore(value: AccountContextAvailability): number {
365
+ if (value === "available") return 1;
366
+ if (value === "partial") return 0.5;
367
+ return 0;
368
+ }
369
+
370
+ function sumBuyDelta(values: AccountSignalsV2["combat"]["killsByBuyDelta"][]): AccountSignalsV2["combat"]["killsByBuyDelta"] {
371
+ const present = values.filter((value): value is NonNullable<typeof value> => value != null);
372
+ if (present.length === 0) return null;
373
+ return {
374
+ disadvantage: sum(present, (value) => value.disadvantage),
375
+ even: sum(present, (value) => value.even),
376
+ advantage: sum(present, (value) => value.advantage)
377
+ };
378
+ }
379
+
380
+ function sumManState(values: AccountSignalsV2["combat"]["killsByManState"][]): AccountSignalsV2["combat"]["killsByManState"] {
381
+ const present = values.filter((value): value is NonNullable<typeof value> => value != null);
382
+ if (present.length === 0) return null;
383
+ return {
384
+ manDown: sum(present, (value) => value.manDown),
385
+ even: sum(present, (value) => value.even),
386
+ manUp: sum(present, (value) => value.manUp)
387
+ };
388
+ }
389
+
390
+ function sumSplit<T>(rows: T[], select: (row: T) => { count: number; won: number }): { count: number; won: number } {
391
+ return {
392
+ count: sum(rows, (row) => select(row).count),
393
+ won: sum(rows, (row) => select(row).won)
394
+ };
395
+ }
396
+
397
+ function sumNullable(values: Array<number | null>): number | null {
398
+ const present = values.filter((value): value is number => value != null);
399
+ if (present.length === 0) return null;
400
+ return sum(present, (value) => value);
401
+ }
402
+
403
+ function weightedNullableRate(rows: RRIndicators[], field: "awpMultiKillRate" | "awpDuelWinRate" | "roundSwingPerKill"): number | null {
404
+ const present = rows
405
+ .filter((row) => row[field] != null)
406
+ .map((row) => ({ value: row[field] ?? 0, weight: row.totalRounds }));
407
+ return present.length === 0 ? null : weightedAverage(present);
408
+ }
409
+
410
+ function weightedAverage(values: Array<{ value: number; weight: number }>): number {
411
+ const weight = sum(values, (row) => row.weight);
412
+ if (weight <= 0) return 0;
413
+ return round(sum(values, (row) => row.value * row.weight) / weight, 4);
414
+ }
415
+
416
+ function mostCommonName(names: Map<string, number>): string {
417
+ return [...names.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? "Unknown";
418
+ }
419
+
420
+ function getOrInit<K, V>(map: Map<K, V>, key: K, create: () => V): V {
421
+ const existing = map.get(key);
422
+ if (existing) return existing;
423
+ const next = create();
424
+ map.set(key, next);
425
+ return next;
426
+ }
427
+
428
+ function sum<T>(rows: T[], select: (row: T) => number): number {
429
+ return rows.reduce((total, row) => total + select(row), 0);
430
+ }
431
+
432
+ function round(value: number, digits = 4): number {
433
+ const factor = 10 ** digits;
434
+ return Math.round(value * factor) / factor;
435
+ }