@cs2dak/core 0.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +68 -88
  8. package/src/index.ts +41 -9
  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 +26 -149
  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/side-win-rate.test.ts +33 -0
  35. package/src/side-win-rate.ts +49 -0
  36. package/src/signals.ts +150 -113
  37. package/src/spatial/annotate.test.ts +133 -0
  38. package/src/spatial/annotate.ts +163 -0
  39. package/src/spatial/index.ts +16 -0
  40. package/src/spatial/mapcontrol.test.ts +131 -0
  41. package/src/spatial/mapcontrol.ts +277 -0
  42. package/src/spatial/phase.test.ts +171 -0
  43. package/src/spatial/phase.ts +179 -0
  44. package/src/spatial/trade-closure.test.ts +38 -0
  45. package/src/spatial/types.ts +56 -0
  46. package/src/spatial/utility-geometry.test.ts +88 -0
  47. package/src/spatial/utility-geometry.ts +167 -0
  48. package/src/spatial/utility.integration.test.ts +38 -0
  49. package/src/spatial/utility.test.ts +120 -0
  50. package/src/spatial/utility.ts +399 -0
  51. package/src/tactics/formations.ts +151 -0
  52. package/src/tactics/index.ts +16 -0
  53. package/src/tactics/replay-round-context.ts +96 -0
  54. package/src/tactics/round-facts.ts +406 -0
  55. package/src/tactics/segments.ts +68 -0
  56. package/src/tactics/tactics.test.ts +112 -0
  57. package/src/tactics/types.ts +73 -0
  58. package/src/timeline.ts +73 -52
  59. package/src/utility-facts.test.ts +55 -0
  60. package/src/utility-facts.ts +137 -0
  61. package/src/utils.ts +27 -25
  62. package/src/weapon-highlights.ts +55 -0
  63. package/src/economy.test.ts +0 -51
  64. package/src/economy.ts +0 -76
  65. package/src/weapons.test.ts +0 -32
  66. package/src/weapons.ts +0 -93
  67. package/src/workspace.ts +0 -726
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)
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage } from "@cs2dak/contract";
3
+ import { buildTeamSideWinRates } from "./side-win-rate";
4
+
5
+ describe("buildTeamSideWinRates", () => {
6
+ it("aggregates T/CT win rates for both teams from canonical rounds", () => {
7
+ const pkg = {
8
+ rounds: [
9
+ { teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamA" },
10
+ { teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamB" },
11
+ { teamASide: "t", teamBSide: "ct", winnerTeamKey: "teamA" }
12
+ ]
13
+ } as DemoPackage;
14
+
15
+ expect(buildTeamSideWinRates(pkg)).toEqual({
16
+ teamA: {
17
+ ct: { played: 2, won: 1, winRate: 0.5 },
18
+ t: { played: 1, won: 1, winRate: 1 }
19
+ },
20
+ teamB: {
21
+ t: { played: 2, won: 1, winRate: 0.5 },
22
+ ct: { played: 1, won: 0, winRate: 0 }
23
+ }
24
+ });
25
+ });
26
+
27
+ it("returns empty team summaries when rounds are unavailable", () => {
28
+ expect(buildTeamSideWinRates({ rounds: [] } as unknown as DemoPackage)).toEqual({
29
+ teamA: {},
30
+ teamB: {}
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,49 @@
1
+ import type { DemoPackage, Side, TeamKey } from "@cs2dak/contract";
2
+
3
+ export interface SideWinRateStats {
4
+ played: number;
5
+ won: number;
6
+ winRate: number;
7
+ }
8
+
9
+ export type TeamSideWinRates = Record<TeamKey, Partial<Record<Side, SideWinRateStats>>>;
10
+
11
+ interface SideRoundResult {
12
+ side: Side;
13
+ won: boolean;
14
+ }
15
+
16
+ function aggregateSideWinRates(rows: SideRoundResult[]): Partial<Record<Side, SideWinRateStats>> {
17
+ const groups = new Map<Side, { played: number; won: number }>();
18
+ for (const row of rows) {
19
+ const group = groups.get(row.side) ?? { played: 0, won: 0 };
20
+ group.played += 1;
21
+ if (row.won) group.won += 1;
22
+ groups.set(row.side, group);
23
+ }
24
+
25
+ return Object.fromEntries(
26
+ [...groups.entries()].map(([side, stats]) => [
27
+ side,
28
+ { ...stats, winRate: stats.played > 0 ? stats.won / stats.played : 0 }
29
+ ])
30
+ );
31
+ }
32
+
33
+ /**
34
+ * 按 T/CT side 汇总一场比赛中两队的回合胜率。
35
+ *
36
+ * 取代产品从数据库行重新拼装的 half-side 聚合;产品只负责选择 DemoPackage。
37
+ */
38
+ export function buildTeamSideWinRates(pkg: DemoPackage): TeamSideWinRates {
39
+ return {
40
+ teamA: aggregateSideWinRates(pkg.rounds.map((round) => ({
41
+ side: round.teamASide,
42
+ won: round.winnerTeamKey === "teamA"
43
+ }))),
44
+ teamB: aggregateSideWinRates(pkg.rounds.map((round) => ({
45
+ side: round.teamBSide,
46
+ won: round.winnerTeamKey === "teamB"
47
+ })))
48
+ };
49
+ }