@cs2dak/core 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.
Files changed (62) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +46 -53
  8. package/src/index.ts +25 -8
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +27 -1
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/signals.ts +149 -112
  35. package/src/spatial/annotate.test.ts +133 -0
  36. package/src/spatial/annotate.ts +163 -0
  37. package/src/spatial/index.ts +16 -0
  38. package/src/spatial/mapcontrol.test.ts +131 -0
  39. package/src/spatial/mapcontrol.ts +277 -0
  40. package/src/spatial/phase.test.ts +171 -0
  41. package/src/spatial/phase.ts +179 -0
  42. package/src/spatial/trade-closure.test.ts +38 -0
  43. package/src/spatial/types.ts +56 -0
  44. package/src/spatial/utility-geometry.test.ts +88 -0
  45. package/src/spatial/utility-geometry.ts +167 -0
  46. package/src/spatial/utility.integration.test.ts +38 -0
  47. package/src/spatial/utility.test.ts +120 -0
  48. package/src/spatial/utility.ts +399 -0
  49. package/src/tactics/formations.ts +151 -0
  50. package/src/tactics/index.ts +16 -0
  51. package/src/tactics/replay-round-context.ts +96 -0
  52. package/src/tactics/round-facts.ts +406 -0
  53. package/src/tactics/segments.ts +68 -0
  54. package/src/tactics/tactics.test.ts +112 -0
  55. package/src/tactics/types.ts +73 -0
  56. package/src/timeline.ts +73 -52
  57. package/src/utility-facts.test.ts +55 -0
  58. package/src/utility-facts.ts +137 -0
  59. package/src/utils.ts +27 -25
  60. package/src/weapon-highlights.ts +4 -4
  61. package/src/economy.test.ts +0 -37
  62. package/src/economy.ts +0 -57
package/src/scoreboard.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  computeRR,
3
3
  computePrism,
4
- rrWeightsV1,
4
+ hltv2BaselineWeightsV1,
5
5
  prismWeightsV1,
6
6
  rrToPercentile,
7
7
  type RRResult,
@@ -10,17 +10,19 @@ import {
10
10
  type PrismComputeInput
11
11
  } from "@rivalhub/rival-rating";
12
12
  import type {
13
- AccountSignalsV2,
14
13
  DemoPackage,
15
14
  PlayerIndicatorRow,
16
15
  PlayerRoundFact,
17
16
  PlayerScoreboardRow,
18
17
  RRIndicators
19
18
  } from "@cs2dak/contract";
20
- import type { RRResultV2 } from "@rivalhub/rival-rating";
19
+ import type { RRSignals } from "@rivalhub/rival-rating";
20
+ import type { AccountRatingResult } from "./signals.js";
21
21
  import { normalizeDemoPackage } from "./normalize.js";
22
- import { round, firstKillMap, clutchSplit, isUtilityWeapon, killWeaponName } from "./utils.js";
22
+ import { activeDamages, round, firstKillMap, clutchSplit, killWeaponName } from "./utils.js";
23
+ import { createResolverFromPackage } from "./resolve.js";
23
24
  import { fieldAvailability, fieldConfidence } from "./qa.js";
25
+ import { buildPlayerRoundUtilityFacts } from "./utility-facts.js";
24
26
 
25
27
  export function deriveRRIndicators(input: unknown): RRIndicators[] {
26
28
  const pkg = normalizeDemoPackage(input);
@@ -28,22 +30,32 @@ export function deriveRRIndicators(input: unknown): RRIndicators[] {
28
30
  }
29
31
 
30
32
  export function buildPlayerRoundFacts(pkg: DemoPackage): PlayerRoundFact[] {
33
+ const resolver = createResolverFromPackage(pkg);
31
34
  const firstKillByRound = firstKillMap(pkg);
35
+ const damageRows = activeDamages(pkg);
36
+ const utilityFacts = new Map(buildPlayerRoundUtilityFacts(pkg).map((fact) => [`${fact.roundNumber}:${fact.steamId64}`, fact]));
32
37
 
33
38
  return pkg.rounds.flatMap((roundRow) =>
34
- pkg.players.map((player) => {
35
- const kills = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.killerSteamId64 === player.steamId64);
36
- const deaths = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.victimSteamId64 === player.steamId64);
37
- const assists = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.assisterSteamId64 === player.steamId64);
38
- const flashAssists = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.flashAssisterSteamId64 === player.steamId64);
39
- const damageRows = pkg.damages.filter((row) => row.roundNumber === roundRow.roundNumber && row.attackerSteamId64 === player.steamId64 && row.victimTeamKey !== player.teamKey);
40
- const economy = pkg.playerEconomies.find((row) => row.roundNumber === roundRow.roundNumber && row.steamId64 === player.steamId64);
39
+ pkg.players.map((player, playerIdx) => {
40
+ const kills = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.killerIndex === playerIdx);
41
+ const deaths = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.victimIndex === playerIdx);
42
+ const assists = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.assisterIndex === playerIdx);
43
+ // Keep the existing PlayerRoundFact assist/KAST semantics (the
44
+ // flashAssists field itself is supplied by the shared utility helper).
45
+ const flashAssistRows = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.flashAssisterIndex === playerIdx);
46
+ const utility = utilityFacts.get(`${roundRow.roundNumber}:${player.steamId64}`)!;
47
+ const playerDamageRows = damageRows.filter((row) =>
48
+ row.roundNumber === roundRow.roundNumber &&
49
+ row.attackerIndex === playerIdx &&
50
+ resolver.byIndexOrNull(row.victimIndex)?.teamKey !== player.teamKey
51
+ );
52
+ const economy = pkg.playerEconomies.find((row) => row.roundNumber === roundRow.roundNumber && row.playerIndex === playerIdx);
41
53
  const side = player.teamKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
42
54
  const firstKill = firstKillByRound.get(roundRow.roundNumber);
43
55
  const kastTags = new Set<PlayerRoundFact["kastTags"][number]>();
44
56
 
45
57
  if (kills.length > 0) kastTags.add("kill");
46
- if (assists.length > 0 || flashAssists.length > 0) kastTags.add("assist");
58
+ if (assists.length > 0 || flashAssistRows.length > 0) kastTags.add("assist");
47
59
  if (deaths.length === 0) kastTags.add("survive");
48
60
  if (deaths.some((death) => death.tradeDeath)) kastTags.add("trade");
49
61
 
@@ -56,13 +68,13 @@ export function buildPlayerRoundFacts(pkg: DemoPackage): PlayerRoundFact[] {
56
68
  survived: deaths.length === 0,
57
69
  kills: kills.length,
58
70
  deaths: deaths.length,
59
- assists: assists.length + flashAssists.length,
60
- damage: damageRows.reduce((sum, row) => sum + row.healthDamage, 0),
61
- utilityDamage: damageRows.filter((row) => isUtilityWeapon(row.weapon)).reduce((sum, row) => sum + row.healthDamage, 0),
62
- flashAssists: flashAssists.length + kills.filter((kill) => kill.flashAssist).length,
71
+ assists: assists.length + flashAssistRows.length,
72
+ damage: playerDamageRows.reduce((sum, row) => sum + row.healthDamage, 0),
73
+ utilityDamage: utility.utilityDamage,
74
+ flashAssists: utility.flashAssists,
63
75
  tradeKills: kills.filter((kill) => kill.tradeKill).length,
64
76
  tradedDeaths: deaths.filter((death) => death.tradeDeath).length,
65
- openingDuel: firstKill?.killerSteamId64 === player.steamId64 ? "won" : firstKill?.victimSteamId64 === player.steamId64 ? "lost" : "none",
77
+ openingDuel: firstKill?.killerIndex === playerIdx ? "won" : firstKill?.victimIndex === playerIdx ? "lost" : "none",
66
78
  kastTags: [...kastTags],
67
79
  equipmentValue: economy?.equipmentValue ?? null,
68
80
  economyType: economy?.type ?? null
@@ -72,14 +84,30 @@ export function buildPlayerRoundFacts(pkg: DemoPackage): PlayerRoundFact[] {
72
84
  }
73
85
 
74
86
  export function buildPlayerIndicators(pkg: DemoPackage, facts: PlayerRoundFact[]): PlayerIndicatorRow[] {
75
- const indicators = pkg.players.map((player) => {
76
- const stats = pkg.playerStats.find((row) => row.steamId64 === player.steamId64);
87
+ const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
88
+ const utilityTotals = new Map<string, {
89
+ utilityDamage: number;
90
+ flashAssists: number;
91
+ enemyBlindSeconds: number;
92
+ teamBlindSeconds: number;
93
+ }>();
94
+ for (const fact of buildPlayerRoundUtilityFacts(pkg)) {
95
+ const total = utilityTotals.get(fact.steamId64) ?? { utilityDamage: 0, flashAssists: 0, enemyBlindSeconds: 0, teamBlindSeconds: 0 };
96
+ total.utilityDamage += fact.utilityDamage;
97
+ total.flashAssists += fact.flashAssists;
98
+ total.enemyBlindSeconds += fact.enemyBlindSeconds;
99
+ total.teamBlindSeconds += fact.teamBlindSeconds;
100
+ utilityTotals.set(fact.steamId64, total);
101
+ }
102
+
103
+ const indicators = pkg.players.map((player, playerIdx) => {
104
+ const stats = statsMap.get(playerIdx);
77
105
  const playerFacts = facts.filter((fact) => fact.steamId64 === player.steamId64);
78
- const playerKills = pkg.kills.filter((kill) => kill.killerSteamId64 === player.steamId64);
79
- const playerDeaths = pkg.kills.filter((kill) => kill.victimSteamId64 === player.steamId64);
80
- const playerEconomies = pkg.playerEconomies.filter((row) => row.steamId64 === player.steamId64);
81
- const playerClutches = pkg.clutches.filter((row) => row.clutcherSteamId64 === player.steamId64);
82
- const playerBlinds = pkg.blinds.filter((row) => row.flasherSteamId64 === player.steamId64);
106
+ const playerKills = pkg.kills.filter((kill) => kill.killerIndex === playerIdx);
107
+ const playerDeaths = pkg.kills.filter((kill) => kill.victimIndex === playerIdx);
108
+ const playerEconomies = pkg.playerEconomies.filter((row) => row.playerIndex === playerIdx);
109
+ const playerClutches = pkg.clutches.filter((row) => row.clutcherIndex === playerIdx);
110
+ const utility = utilityTotals.get(player.steamId64) ?? { utilityDamage: 0, flashAssists: 0, enemyBlindSeconds: 0, teamBlindSeconds: 0 };
83
111
  const totalRounds = Math.max(playerFacts.length, 1);
84
112
  const killsByRound = new Map<number, number>();
85
113
  for (const kill of playerKills) {
@@ -91,15 +119,11 @@ export function buildPlayerIndicators(pkg: DemoPackage, facts: PlayerRoundFact[]
91
119
  const openingDuels = firstKillCount + firstDeathCount;
92
120
  const awpKills = playerKills.filter((kill) => killWeaponName(kill) === "awp").length;
93
121
  const sniperKills = playerKills.filter((kill) => ["awp", "ssg08", "scout"].includes(killWeaponName(kill))).length;
94
- const utilityDamage = stats?.utilityDamage ?? playerFacts.reduce((sum, fact) => sum + fact.utilityDamage, 0);
95
- const flashAssistCount = stats?.flashAssistCount ?? playerFacts.reduce((sum, fact) => sum + fact.flashAssists, 0);
96
- const enemyFlashDurationSeconds = stats?.enemyFlashDurationSeconds ?? playerBlinds
97
- .filter((blind) => blind.flashedTeamKey && blind.flashedTeamKey !== player.teamKey)
98
- .reduce((sum, blind) => sum + blind.durationSeconds, 0);
99
- const teamFlashDurationSeconds = stats?.teamFlashDurationSeconds ?? playerBlinds
100
- .filter((blind) => blind.flashedTeamKey === player.teamKey && blind.flashedSteamId64 !== player.steamId64)
101
- .reduce((sum, blind) => sum + blind.durationSeconds, 0);
102
- const grenadeCount = pkg.grenades.filter((grenade) => grenade.throwerSteamId64 === player.steamId64).length;
122
+ const utilityDamage = stats?.utilityDamage ?? utility.utilityDamage;
123
+ const flashAssistCount = stats?.flashAssistCount ?? utility.flashAssists;
124
+ const enemyFlashDurationSeconds = stats?.enemyFlashDurationSeconds ?? utility.enemyBlindSeconds;
125
+ const teamFlashDurationSeconds = stats?.teamFlashDurationSeconds ?? utility.teamBlindSeconds;
126
+ const grenadeCount = pkg.grenades.filter((grenade) => grenade.throwerIndex === playerIdx).length;
103
127
  const deaths = stats?.deaths ?? playerDeaths.length;
104
128
  const kills = stats?.kills ?? playerKills.length;
105
129
  const assists = stats?.assists ?? playerFacts.reduce((sum, fact) => sum + fact.assists, 0);
@@ -185,7 +209,7 @@ export function buildPlayerIndicators(pkg: DemoPackage, facts: PlayerRoundFact[]
185
209
  } satisfies RRIndicators;
186
210
  });
187
211
 
188
- const rrWeights = rrWeightsV1 as unknown as RRWeights;
212
+ const rrWeights = hltv2BaselineWeightsV1 as unknown as RRWeights;
189
213
  const prismWeights = prismWeightsV1 as unknown as PrismWeights;
190
214
  const rrResults = indicators.map((indicator) => computeRR(indicator, rrWeights));
191
215
  const rrScores = rrResults.map((result) => result.rr);
@@ -213,22 +237,27 @@ export function buildPlayerIndicators(pkg: DemoPackage, facts: PlayerRoundFact[]
213
237
  export function buildScoreboard(
214
238
  pkg: DemoPackage,
215
239
  rows: PlayerIndicatorRow[],
216
- accountRatings: Array<{ signals: AccountSignalsV2; rr: RRResultV2 }>
240
+ accountRatings: Array<{ signals: RRSignals; rr: AccountRatingResult }>
217
241
  ): PlayerScoreboardRow[] {
242
+ const resolver = createResolverFromPackage(pkg);
218
243
  const accountBySteamId = new Map(accountRatings.map((row) => [row.signals.steamId64, row]));
219
- const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
244
+ const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
220
245
  const availability = fieldAvailability(pkg);
221
246
  const confidence = fieldConfidence(availability);
222
247
  return rows.map((row) => {
223
248
  const account = accountBySteamId.get(row.steamId64);
224
249
  const accountRr = account?.rr;
225
250
  const combatSignals = account?.signals.combat;
226
- const stats = statsBySteamId.get(row.steamId64);
227
- const playerKills = pkg.kills.filter((kill) => kill.killerSteamId64 === row.steamId64);
251
+ const playerIdx = resolver.indexOfSteamId(row.steamId64);
252
+ const stats = playerIdx != null ? statsMap.get(playerIdx) : undefined;
253
+ const playerKills = playerIdx != null
254
+ ? pkg.kills.filter((kill) => kill.killerIndex === playerIdx)
255
+ : [];
228
256
  return {
229
257
  steamId64: row.steamId64,
230
258
  name: row.name,
231
259
  teamKey: row.teamKey,
260
+ indicators: row.indicators,
232
261
  kills: row.indicators.kills,
233
262
  deaths: row.indicators.deaths,
234
263
  assists: row.indicators.assists,
@@ -258,6 +287,7 @@ export function buildScoreboard(
258
287
  accountBreakdown: {
259
288
  combat: round(accountRr?.accounts.combat ?? 0, 4),
260
289
  trade: round(accountRr?.accounts.trade ?? 0, 4),
290
+ mapControl: round(accountRr?.accounts.mapControl ?? 0, 4),
261
291
  clutch: round(accountRr?.accounts.clutch ?? 0, 4),
262
292
  objective: round(accountRr?.accounts.objective ?? 0, 4),
263
293
  utility: round(accountRr?.accounts.utility ?? 0, 4)
package/src/signals.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import {
2
- computeLeagueMeanV2,
3
- computeValueAccountsRR,
4
- rrValueAccountsV2Lite
2
+ computeFrozenProBaselineRR,
3
+ computeRRSixAccounts,
4
+ rrSixAccountProBaselineV0,
5
+ rrSixAccountWeightsV1
5
6
  } from "@rivalhub/rival-rating";
6
- import type { AccountSignalsV2, DemoPackage, ValueAccountsWeights } from "@cs2dak/contract";
7
- import type { RRResultV2 } from "@rivalhub/rival-rating";
7
+ import type { DemoPackage, RRSignals, RRSixAccountWeights } from "@cs2dak/contract";
8
+ import type { CohortAccountResult, ProBaselineConfig, RRSixAccountResult } from "@rivalhub/rival-rating";
8
9
  import { normalizeDemoPackage } from "./normalize.js";
10
+ import { loadSpatialAssets } from "./spatial/annotate.js";
11
+ import { buildOfficialMapControl } from "./spatial/mapcontrol.js";
12
+ import { createResolverFromPackage, type PlayerResolver } from "./resolve.js";
13
+ import { buildPlayerRoundUtilityFacts } from "./utility-facts.js";
9
14
  import {
10
15
  type BuyDeltaBuckets,
11
16
  type ManStateBuckets,
@@ -15,7 +20,7 @@ import {
15
20
  getOrInit,
16
21
  zeroBuyDelta,
17
22
  zeroManState,
18
- clutchSplitV2,
23
+ clutchSplit,
19
24
  sumDamageForPlayer,
20
25
  openingKillsForPlayer,
21
26
  openingDeathsForPlayer,
@@ -24,25 +29,31 @@ import {
24
29
  firstKillMap
25
30
  } from "./utils.js";
26
31
 
27
- export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
32
+ export type AccountRatingResult = CohortAccountResult & Pick<RRSixAccountResult, "combatContextFactor" | "weightsVersion" | "model">;
33
+
34
+ export function deriveRRSignals(input: unknown): RRSignals[] {
28
35
  const pkg = normalizeDemoPackage(input);
29
- const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
30
- const killsByBuyDelta = buildKillsByBuyDelta(pkg);
31
- const killsByManState = buildKillsByManState(pkg);
32
- const tradedOpeningDeaths = buildTradedOpeningDeaths(pkg);
33
- const objective = buildObjectiveSignals(pkg);
34
- const utility = buildUtilitySignals(pkg);
35
-
36
- // context 分桶的"数据源是否存在"。源缺失 → 发 null(模型降级为乘子 1.0),
37
- // 而非零桶——零桶语义是"源在、但该选手无相关样本"。区分二者是 v2 可信展示的前提。
36
+ const resolver = createResolverFromPackage(pkg);
37
+ const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
38
+ const killsByBuyDelta = buildKillsByBuyDelta(pkg, resolver);
39
+ const killsByManState = buildKillsByManState(pkg, resolver);
40
+ const tradedOpeningDeaths = buildTradedOpeningDeaths(pkg, resolver);
41
+ const objective = buildObjectiveSignals(pkg, resolver);
42
+ const utility = buildUtilitySignals(pkg, statsMap);
43
+
38
44
  const buyDeltaAvailable = pkg.playerEconomies.length > 0;
39
45
  const manStateAvailable = pkg.rounds.length > 0;
40
46
 
41
- return pkg.players.map((player) => {
42
- const stats = statsBySteamId.get(player.steamId64);
43
- const playerKills = pkg.kills.filter((kill) => kill.killerSteamId64 === player.steamId64);
44
- const playerDeaths = pkg.kills.filter((kill) => kill.victimSteamId64 === player.steamId64);
45
- const playerClutches = pkg.clutches.filter((row) => row.clutcherSteamId64 === player.steamId64);
47
+ const spatialAssets = loadSpatialAssets(pkg.match?.mapName ?? pkg.manifest?.mapName ?? "");
48
+ const officialMapControl = buildOfficialMapControl(pkg, spatialAssets);
49
+ // replay-based spatial re-impl (step 3)
50
+ const spatialObservable = spatialAssets.routes != null && (pkg.replay?.rounds?.length ?? 0) > 0;
51
+
52
+ return pkg.players.map((player, playerIdx) => {
53
+ const stats = statsMap.get(playerIdx);
54
+ const playerKills = pkg.kills.filter((kill) => kill.killerIndex === playerIdx);
55
+ const playerDeaths = pkg.kills.filter((kill) => kill.victimIndex === playerIdx);
56
+ const playerClutches = pkg.clutches.filter((row) => row.clutcherIndex === playerIdx);
46
57
  const rounds = Math.max(stats?.rounds ?? pkg.rounds.length, 0);
47
58
 
48
59
  return {
@@ -53,9 +64,9 @@ export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
53
64
  kills: stats?.kills ?? playerKills.length,
54
65
  deaths: stats?.deaths ?? playerDeaths.length,
55
66
  assists: stats?.assists ?? 0,
56
- effectiveDamage: stats?.damageHealth ?? sumDamageForPlayer(pkg, player.steamId64),
57
- openingKills: stats?.firstKillCount ?? openingKillsForPlayer(pkg, player.steamId64),
58
- openingDeaths: stats?.firstDeathCount ?? openingDeathsForPlayer(pkg, player.steamId64),
67
+ effectiveDamage: stats?.damageHealth ?? sumDamageForPlayer(pkg, playerIdx),
68
+ openingKills: stats?.firstKillCount ?? openingKillsForPlayer(pkg, playerIdx),
69
+ openingDeaths: stats?.firstDeathCount ?? openingDeathsForPlayer(pkg, playerIdx),
59
70
  multiKills: {
60
71
  two: stats?.twoKillCount ?? multiKillRounds(playerKills, 2),
61
72
  three: stats?.threeKillCount ?? multiKillRounds(playerKills, 3),
@@ -71,55 +82,78 @@ export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
71
82
  tradeKills: stats?.tradeKillCount ?? playerKills.filter((kill) => kill.tradeKill).length,
72
83
  tradedDeaths: stats?.tradeDeathCount ?? playerDeaths.filter((kill) => kill.tradeDeath).length,
73
84
  deaths: stats?.deaths ?? playerDeaths.length,
74
- tradedOpeningDeaths: tradedOpeningDeaths.get(player.steamId64) ?? 0
85
+ tradedOpeningDeaths: tradedOpeningDeaths.get(player.steamId64) ?? 0,
86
+ strategicIsolationDeaths: spatialObservable
87
+ ? (officialMapControl.get(player.steamId64)?.strategicIsolationDeaths ?? 0)
88
+ : null
89
+ },
90
+ mapControl: {
91
+ uniqueStrategicControlSeconds: null,
92
+ contestedFrontierControlSeconds: null,
93
+ routeDenialSeconds: null,
94
+ teammateAdvanceUnits: null,
95
+ firstControlEvents: null
75
96
  },
76
97
  clutch: {
77
- vsOne: clutchSplitV2(stats?.vsOneCount, stats?.vsOneWonCount, playerClutches, 1),
78
- vsTwo: clutchSplitV2(stats?.vsTwoCount, stats?.vsTwoWonCount, playerClutches, 2),
79
- vsThree: clutchSplitV2(stats?.vsThreeCount, stats?.vsThreeWonCount, playerClutches, 3),
80
- vsFour: clutchSplitV2(stats?.vsFourCount, stats?.vsFourWonCount, playerClutches, 4),
81
- vsFive: clutchSplitV2(stats?.vsFiveCount, stats?.vsFiveWonCount, playerClutches, 5)
98
+ vsOne: clutchSplit(stats?.vsOneCount, stats?.vsOneWonCount, playerClutches, 1),
99
+ vsTwo: clutchSplit(stats?.vsTwoCount, stats?.vsTwoWonCount, playerClutches, 2),
100
+ vsThree: clutchSplit(stats?.vsThreeCount, stats?.vsThreeWonCount, playerClutches, 3),
101
+ vsFour: clutchSplit(stats?.vsFourCount, stats?.vsFourWonCount, playerClutches, 4),
102
+ vsFive: clutchSplit(stats?.vsFiveCount, stats?.vsFiveWonCount, playerClutches, 5)
82
103
  },
83
104
  objective: objective.get(player.steamId64) ?? { plants: 0, defuses: 0, plantsConverted: 0 },
84
- utility: utility.get(player.steamId64) ?? {
85
- flashAssists: stats?.flashAssistCount ?? 0,
86
- enemyFlashDurationSeconds: stats?.enemyFlashDurationSeconds ?? 0,
87
- teamFlashDurationSeconds: stats?.teamFlashDurationSeconds ?? 0,
88
- utilityDamage: stats?.utilityDamage ?? 0
105
+ utility: {
106
+ ...(utility.get(player.steamId64) ?? {
107
+ flashAssists: stats?.flashAssistCount ?? 0,
108
+ effectiveEnemyFlashSeconds: stats?.enemyFlashDurationSeconds ?? 0,
109
+ teamFlashSuppressionSeconds: stats?.teamFlashDurationSeconds ?? 0,
110
+ smokeProtectedCrossings: null,
111
+ smokeSightlineDenialSeconds: null,
112
+ smokeIsolationSeconds: null,
113
+ incendiaryPathDelayUnits: null,
114
+ incendiaryDisplacementEvents: null,
115
+ utilityDamage: stats?.utilityDamage ?? 0
116
+ })
89
117
  }
90
- } satisfies AccountSignalsV2;
118
+ } satisfies RRSignals;
91
119
  });
92
120
  }
93
121
 
94
- export function computeAccountRatingsV2(input: unknown): Array<{ signals: AccountSignalsV2; rr: RRResultV2 }> {
95
- const weights = rrValueAccountsV2Lite as unknown as ValueAccountsWeights;
96
- const rated = deriveAccountSignalsV2(input).map((signals) => ({
97
- signals,
98
- rr: computeValueAccountsRR(signals, weights)
99
- }));
122
+ export function computeAccountRatingsV2(input: unknown): Array<{ signals: RRSignals; rr: AccountRatingResult }> {
123
+ const weights = rrSixAccountWeightsV1 as unknown as RRSixAccountWeights;
124
+ const baseline = rrSixAccountProBaselineV0 as unknown as ProBaselineConfig;
125
+ const signals = deriveRRSignals(input);
126
+ const rawBySteamId = new Map(signals.map((row) => [row.steamId64, computeRRSixAccounts(row, weights)]));
127
+ const balanced = signals.map((signal) => computeFrozenProBaselineRR(signal, weights, baseline));
100
128
 
101
- // 联赛锚定(weights.anchor.mode === "league_mean"):整体乘 (1.0 / mean),
102
- // 使 1.00 = 本场均值。本层一次只处理单场,故"联赛均值"= 本场均值(per-match);
103
- // 真正的赛季级锚定属于上游跨场聚合层,详见 docs/design/rr-v2-lite.md。
104
- const mean = computeLeagueMeanV2(rated.map((row) => row.rr));
105
- const factor = mean > 0 ? 1.0 / mean : 1.0;
106
- return rated.map((row) => ({
107
- signals: row.signals,
108
- rr: { ...row.rr, rr: row.rr.rr * factor }
109
- }));
129
+ return balanced.map((rr, index) => {
130
+ const signal = signals[index]!;
131
+ const raw = rawBySteamId.get(signal.steamId64);
132
+ return {
133
+ signals: signal,
134
+ rr: {
135
+ ...rr,
136
+ combatContextFactor: raw?.combatContextFactor ?? 1,
137
+ weightsVersion: raw?.weightsVersion ?? weights.version,
138
+ model: raw?.model ?? "rr-six-accounts"
139
+ }
140
+ };
141
+ });
110
142
  }
111
143
 
112
- function buildKillsByBuyDelta(pkg: DemoPackage): Map<string, BuyDeltaBuckets> {
144
+ function buildKillsByBuyDelta(pkg: DemoPackage, resolver: PlayerResolver): Map<string, BuyDeltaBuckets> {
113
145
  const out = new Map<string, BuyDeltaBuckets>();
114
- const economyByPlayerRound = new Map(pkg.playerEconomies.map((row) => [`${row.roundNumber}:${row.steamId64}`, row]));
146
+ const economyByPlayerRound = new Map(pkg.playerEconomies.map((row) => [`${row.roundNumber}:${row.playerIndex}`, row]));
115
147
 
116
148
  for (const kill of pkg.kills) {
117
- if (!kill.killerSteamId64) continue;
118
- const killerEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.killerSteamId64}`);
119
- const victimEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.victimSteamId64}`);
149
+ if (kill.killerIndex === null) continue;
150
+ const killerEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.killerIndex}`);
151
+ const victimEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.victimIndex}`);
120
152
  if (!killerEconomy || !victimEconomy) continue;
121
153
 
122
- const buckets = getOrInit(out, kill.killerSteamId64, zeroBuyDelta);
154
+ const killerSteamId = resolver.steamIdOf(kill.killerIndex);
155
+ if (!killerSteamId) continue;
156
+ const buckets = getOrInit(out, killerSteamId, zeroBuyDelta);
123
157
  const delta = killerEconomy.equipmentValue - victimEconomy.equipmentValue;
124
158
  if (delta <= -BUY_DELTA_EVEN_THRESHOLD) {
125
159
  buckets.disadvantage += 1;
@@ -133,63 +167,68 @@ function buildKillsByBuyDelta(pkg: DemoPackage): Map<string, BuyDeltaBuckets> {
133
167
  return out;
134
168
  }
135
169
 
136
- function buildKillsByManState(pkg: DemoPackage): Map<string, ManStateBuckets> {
170
+ function buildKillsByManState(pkg: DemoPackage, resolver: PlayerResolver): Map<string, ManStateBuckets> {
137
171
  const out = new Map<string, ManStateBuckets>();
138
- const playersByTeam = {
139
- teamA: pkg.players.filter((player) => player.teamKey === "teamA").map((player) => player.steamId64),
140
- teamB: pkg.players.filter((player) => player.teamKey === "teamB").map((player) => player.steamId64)
141
- };
172
+ const teamAIndices = new Set(pkg.players.flatMap((p, i) => p.teamKey === "teamA" ? [i] : []));
173
+ const teamBIndices = new Set(pkg.players.flatMap((p, i) => p.teamKey === "teamB" ? [i] : []));
142
174
 
143
175
  for (const roundRow of pkg.rounds) {
144
- const alive = {
145
- teamA: new Set(playersByTeam.teamA),
146
- teamB: new Set(playersByTeam.teamB)
147
- };
176
+ const aliveA = new Set(teamAIndices);
177
+ const aliveB = new Set(teamBIndices);
148
178
  const roundKills = pkg.kills
149
179
  .filter((kill) => kill.roundNumber === roundRow.roundNumber)
150
180
  .sort((a, b) => a.tick - b.tick);
151
181
 
152
182
  for (const kill of roundKills) {
153
- if (kill.killerSteamId64 && kill.killerTeamKey && kill.killerTeamKey !== kill.victimTeamKey) {
154
- const buckets = getOrInit(out, kill.killerSteamId64, zeroManState);
155
- const killerAlive = alive[kill.killerTeamKey].size;
156
- const victimAlive = alive[kill.victimTeamKey].size;
157
- const diff = killerAlive - victimAlive;
158
- if (diff < 0) {
159
- buckets.manDown += 1;
160
- } else if (diff > 0) {
161
- buckets.manUp += 1;
162
- } else {
163
- buckets.even += 1;
164
- }
183
+ if (kill.killerIndex === null) continue;
184
+ const killerPlayer = resolver.byIndexOrNull(kill.killerIndex);
185
+ const victimPlayer = resolver.byIndexOrNull(kill.victimIndex);
186
+ if (!killerPlayer || !victimPlayer || killerPlayer.teamKey === victimPlayer.teamKey) continue;
187
+
188
+ const killerSteamId = killerPlayer.steamId64;
189
+ const buckets = getOrInit(out, killerSteamId, zeroManState);
190
+ const killerAlive = killerPlayer.teamKey === "teamA" ? aliveA.size : aliveB.size;
191
+ const victimAlive = victimPlayer.teamKey === "teamA" ? aliveA.size : aliveB.size;
192
+ const diff = killerAlive - victimAlive;
193
+ if (diff < 0) {
194
+ buckets.manDown += 1;
195
+ } else if (diff > 0) {
196
+ buckets.manUp += 1;
197
+ } else {
198
+ buckets.even += 1;
165
199
  }
166
- alive[kill.victimTeamKey].delete(kill.victimSteamId64);
200
+
201
+ if (victimPlayer.teamKey === "teamA") aliveA.delete(kill.victimIndex);
202
+ else aliveB.delete(kill.victimIndex);
167
203
  }
168
204
  }
169
205
 
170
206
  return out;
171
207
  }
172
208
 
173
- function buildTradedOpeningDeaths(pkg: DemoPackage): Map<string, number> {
209
+ function buildTradedOpeningDeaths(pkg: DemoPackage, resolver: PlayerResolver): Map<string, number> {
174
210
  const out = new Map<string, number>();
175
211
  for (const kill of firstKillMap(pkg).values()) {
176
212
  if (kill.tradeDeath) {
177
- out.set(kill.victimSteamId64, (out.get(kill.victimSteamId64) ?? 0) + 1);
213
+ const victimSteamId = resolver.steamIdOf(kill.victimIndex);
214
+ if (victimSteamId) out.set(victimSteamId, (out.get(victimSteamId) ?? 0) + 1);
178
215
  }
179
216
  }
180
217
  return out;
181
218
  }
182
219
 
183
- function buildObjectiveSignals(pkg: DemoPackage): Map<string, ObjectiveBuckets> {
220
+ function buildObjectiveSignals(pkg: DemoPackage, resolver: PlayerResolver): Map<string, ObjectiveBuckets> {
184
221
  const out = new Map<string, ObjectiveBuckets>();
185
222
  const roundWinner = new Map(pkg.rounds.map((round) => [round.roundNumber, round.winnerTeamKey]));
186
223
 
187
224
  for (const bomb of pkg.bombs) {
188
- if (!bomb.actorSteamId64) continue;
189
- const buckets = getOrInit(out, bomb.actorSteamId64, () => ({ plants: 0, defuses: 0, plantsConverted: 0 }));
225
+ if (bomb.actorIndex === null) continue;
226
+ const actorPlayer = resolver.byIndexOrNull(bomb.actorIndex);
227
+ if (!actorPlayer) continue;
228
+ const buckets = getOrInit(out, actorPlayer.steamId64, () => ({ plants: 0, defuses: 0, plantsConverted: 0 }));
190
229
  if (bomb.type === "planted") {
191
230
  buckets.plants += 1;
192
- if (bomb.actorTeamKey && roundWinner.get(bomb.roundNumber) === bomb.actorTeamKey) {
231
+ if (roundWinner.get(bomb.roundNumber) === actorPlayer.teamKey) {
193
232
  buckets.plantsConverted = (buckets.plantsConverted ?? 0) + 1;
194
233
  }
195
234
  } else if (bomb.type === "defused") {
@@ -200,33 +239,31 @@ function buildObjectiveSignals(pkg: DemoPackage): Map<string, ObjectiveBuckets>
200
239
  return out;
201
240
  }
202
241
 
203
- function buildUtilitySignals(pkg: DemoPackage): Map<string, UtilityBuckets> {
204
- const out = new Map<string, UtilityBuckets>();
205
- const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
206
-
207
- for (const player of pkg.players) {
208
- const stats = statsBySteamId.get(player.steamId64);
209
- out.set(player.steamId64, {
210
- flashAssists: stats?.flashAssistCount ?? pkg.kills.filter((kill) => kill.flashAssisterSteamId64 === player.steamId64).length,
211
- enemyFlashDurationSeconds: stats?.enemyFlashDurationSeconds ?? 0,
212
- teamFlashDurationSeconds: stats?.teamFlashDurationSeconds ?? 0,
213
- utilityDamage: stats?.utilityDamage ?? 0
214
- });
242
+ function buildUtilitySignals(pkg: DemoPackage, statsMap: Map<number, DemoPackage["playerStats"][number]>): Map<string, UtilityBuckets> {
243
+ const utilityFacts = buildPlayerRoundUtilityFacts(pkg);
244
+ const totals = new Map<string, { flashAssists: number; enemyBlindSeconds: number; teamBlindSeconds: number; utilityDamage: number }>();
245
+ for (const fact of utilityFacts) {
246
+ const total = totals.get(fact.steamId64) ?? { flashAssists: 0, enemyBlindSeconds: 0, teamBlindSeconds: 0, utilityDamage: 0 };
247
+ total.flashAssists += fact.flashAssists;
248
+ total.enemyBlindSeconds += fact.enemyBlindSeconds;
249
+ total.teamBlindSeconds += fact.teamBlindSeconds;
250
+ total.utilityDamage += fact.utilityDamage;
251
+ totals.set(fact.steamId64, total);
215
252
  }
216
253
 
217
- for (const blind of pkg.blinds) {
218
- const buckets = getOrInit(out, blind.flasherSteamId64, () => ({
219
- flashAssists: 0,
220
- enemyFlashDurationSeconds: 0,
221
- teamFlashDurationSeconds: 0,
222
- utilityDamage: 0
223
- }));
224
- if (blind.flasherTeamKey !== blind.flashedTeamKey) {
225
- buckets.enemyFlashDurationSeconds = round(buckets.enemyFlashDurationSeconds + blind.durationSeconds, 3);
226
- } else if (blind.flashedSteamId64 !== blind.flasherSteamId64) {
227
- buckets.teamFlashDurationSeconds = round((buckets.teamFlashDurationSeconds ?? 0) + blind.durationSeconds, 3);
228
- }
229
- }
230
-
231
- return out;
254
+ return new Map(pkg.players.map((player, playerIdx) => {
255
+ const stats = statsMap.get(playerIdx);
256
+ const fact = totals.get(player.steamId64) ?? { flashAssists: 0, enemyBlindSeconds: 0, teamBlindSeconds: 0, utilityDamage: 0 };
257
+ return [player.steamId64, {
258
+ flashAssists: stats?.flashAssistCount ?? fact.flashAssists,
259
+ effectiveEnemyFlashSeconds: stats?.enemyFlashDurationSeconds ?? round(fact.enemyBlindSeconds, 3),
260
+ teamFlashSuppressionSeconds: stats?.teamFlashDurationSeconds ?? round(fact.teamBlindSeconds, 3),
261
+ smokeProtectedCrossings: null,
262
+ smokeSightlineDenialSeconds: null,
263
+ smokeIsolationSeconds: null,
264
+ incendiaryPathDelayUnits: null,
265
+ incendiaryDisplacementEvents: null,
266
+ utilityDamage: stats?.utilityDamage ?? fact.utilityDamage
267
+ } satisfies UtilityBuckets];
268
+ }));
232
269
  }