@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.
- package/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +68 -88
- package/src/index.ts +41 -9
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +26 -149
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- 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
|
-
|
|
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 {
|
|
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,
|
|
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.
|
|
36
|
-
const deaths = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.
|
|
37
|
-
const assists = pkg.kills.filter((kill) => kill.roundNumber === roundRow.roundNumber && kill.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
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 ||
|
|
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 +
|
|
60
|
-
damage:
|
|
61
|
-
utilityDamage:
|
|
62
|
-
flashAssists: flashAssists
|
|
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?.
|
|
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
|
|
76
|
-
|
|
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.
|
|
79
|
-
const playerDeaths = pkg.kills.filter((kill) => kill.
|
|
80
|
-
const playerEconomies = pkg.playerEconomies.filter((row) => row.
|
|
81
|
-
const playerClutches = pkg.clutches.filter((row) => row.
|
|
82
|
-
const
|
|
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 ??
|
|
95
|
-
const flashAssistCount = stats?.flashAssistCount ??
|
|
96
|
-
const enemyFlashDurationSeconds = stats?.enemyFlashDurationSeconds ??
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
227
|
-
const
|
|
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
|
+
}
|