@cs2dak/core 0.2.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 +21 -0
- package/README.md +7 -0
- package/package.json +20 -0
- package/src/economy.test.ts +51 -0
- package/src/economy.ts +76 -0
- package/src/index.test.ts +184 -0
- package/src/index.ts +49 -0
- package/src/loader.ts +48 -0
- package/src/normalize.ts +158 -0
- package/src/qa.ts +198 -0
- package/src/scoreboard.ts +288 -0
- package/src/signals.ts +232 -0
- package/src/timeline.ts +209 -0
- package/src/utils.ts +133 -0
- package/src/weapons.test.ts +32 -0
- package/src/weapons.ts +93 -0
- package/src/workspace.ts +726 -0
package/src/qa.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { DemoPackage, PlayerScoreboardRow, QaIssue, QaReport } from "@cs2dak/contract";
|
|
2
|
+
import { isNamedWeapon, normalizeWeapon, round } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export type ScoreboardFieldAvailability = PlayerScoreboardRow["fieldAvailability"];
|
|
5
|
+
|
|
6
|
+
export function buildQaReport(pkg: DemoPackage): QaReport {
|
|
7
|
+
const issues: QaIssue[] = [];
|
|
8
|
+
const roundNumbers = pkg.rounds.map((round) => round.roundNumber).sort((a, b) => a - b);
|
|
9
|
+
const roundsByNumber = new Map(pkg.rounds.map((round) => [round.roundNumber, round]));
|
|
10
|
+
const playerIds = new Set(pkg.players.map((player) => player.steamId64));
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < roundNumbers.length; i += 1) {
|
|
13
|
+
if (roundNumbers[i] !== i + 1) {
|
|
14
|
+
issues.push({
|
|
15
|
+
severity: "error",
|
|
16
|
+
code: "rounds.not_contiguous",
|
|
17
|
+
message: `Round numbers should be contiguous from 1; found ${roundNumbers[i]} at index ${i}.`,
|
|
18
|
+
path: "rounds"
|
|
19
|
+
});
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const teamAScore = pkg.rounds.filter((round) => round.winnerTeamKey === "teamA").length;
|
|
25
|
+
const teamBScore = pkg.rounds.filter((round) => round.winnerTeamKey === "teamB").length;
|
|
26
|
+
if (teamAScore !== pkg.match.teamA.score || teamBScore !== pkg.match.teamB.score) {
|
|
27
|
+
issues.push({
|
|
28
|
+
severity: "error",
|
|
29
|
+
code: "score.round_winners_mismatch",
|
|
30
|
+
message: `Match score is ${pkg.match.teamA.score}:${pkg.match.teamB.score}, but rounds imply ${teamAScore}:${teamBScore}.`,
|
|
31
|
+
path: "match"
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const roundRow of pkg.rounds) {
|
|
36
|
+
if (!(roundRow.startTick <= roundRow.freezeEndTick && roundRow.freezeEndTick <= roundRow.endTick)) {
|
|
37
|
+
issues.push({
|
|
38
|
+
severity: "error",
|
|
39
|
+
code: "rounds.invalid_tick_order",
|
|
40
|
+
message: `Round ${roundRow.roundNumber} has invalid tick order.`,
|
|
41
|
+
path: "rounds"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const expectedWinnerSide = roundRow.winnerTeamKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
|
|
45
|
+
if (roundRow.winnerSide !== expectedWinnerSide) {
|
|
46
|
+
issues.push({
|
|
47
|
+
severity: "error",
|
|
48
|
+
code: "rounds.winner_side_mismatch",
|
|
49
|
+
message: `Round ${roundRow.roundNumber} winnerSide does not match winnerTeamKey side.`,
|
|
50
|
+
path: "rounds"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const checkEventTick = (kind: string, roundNumber: number, tick: number, index: number, allowFreeze = false) => {
|
|
56
|
+
const roundRow = roundsByNumber.get(roundNumber);
|
|
57
|
+
if (!roundRow) {
|
|
58
|
+
issues.push({
|
|
59
|
+
severity: "error",
|
|
60
|
+
code: `${kind}.unknown_round`,
|
|
61
|
+
message: `${kind} event ${index} references missing round ${roundNumber}.`,
|
|
62
|
+
path: kind
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const minTick = allowFreeze ? roundRow.startTick : roundRow.freezeEndTick;
|
|
67
|
+
if (tick < minTick || tick > roundRow.endTick) {
|
|
68
|
+
issues.push({
|
|
69
|
+
severity: "error",
|
|
70
|
+
code: `${kind}.tick_outside_round`,
|
|
71
|
+
message: `${kind} event ${index} tick ${tick} is outside round ${roundNumber} active window.`,
|
|
72
|
+
path: kind
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const expectedEconomyRows = pkg.players.length * pkg.rounds.length;
|
|
78
|
+
if (pkg.playerEconomies.length < expectedEconomyRows) {
|
|
79
|
+
issues.push({
|
|
80
|
+
severity: "warning",
|
|
81
|
+
code: "economy.coverage_incomplete",
|
|
82
|
+
message: `Expected ${expectedEconomyRows} player economy rows, found ${pkg.playerEconomies.length}.`,
|
|
83
|
+
path: "playerEconomies"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pkg.kills.forEach((kill, index) => {
|
|
88
|
+
checkEventTick("kills", kill.roundNumber, kill.tick, index);
|
|
89
|
+
if (kill.killerSteamId64 && !playerIds.has(kill.killerSteamId64)) {
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: "warning",
|
|
92
|
+
code: "kill.unknown_killer",
|
|
93
|
+
message: `Killer ${kill.killerSteamId64} is not present in players.json.`,
|
|
94
|
+
path: "kills"
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (!playerIds.has(kill.victimSteamId64)) {
|
|
98
|
+
issues.push({
|
|
99
|
+
severity: "error",
|
|
100
|
+
code: "kill.unknown_victim",
|
|
101
|
+
message: `Victim ${kill.victimSteamId64} is not present in players.json.`,
|
|
102
|
+
path: "kills"
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pkg.damages.forEach((damage, index) => checkEventTick("damages", damage.roundNumber, damage.tick, index));
|
|
108
|
+
pkg.blinds.forEach((blind, index) => checkEventTick("blinds", blind.roundNumber, blind.tick, index));
|
|
109
|
+
pkg.grenades.forEach((grenade, index) => {
|
|
110
|
+
checkEventTick("grenades", grenade.roundNumber, grenade.throwTick, index);
|
|
111
|
+
checkEventTick("grenades", grenade.roundNumber, grenade.effectTick, index);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const bombEventsByRound = new Map<number, typeof pkg.bombs>();
|
|
115
|
+
pkg.bombs.forEach((bomb, index) => {
|
|
116
|
+
checkEventTick("bombs", bomb.roundNumber, bomb.tick, index);
|
|
117
|
+
const events = bombEventsByRound.get(bomb.roundNumber) ?? [];
|
|
118
|
+
events.push(bomb);
|
|
119
|
+
bombEventsByRound.set(bomb.roundNumber, events);
|
|
120
|
+
if (bomb.actorSteamId64 && !playerIds.has(bomb.actorSteamId64)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
severity: "warning",
|
|
123
|
+
code: "bomb.unknown_actor",
|
|
124
|
+
message: `Bomb actor ${bomb.actorSteamId64} is not present in players.json.`,
|
|
125
|
+
path: "bombs"
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
for (const [roundNumber, bombs] of bombEventsByRound) {
|
|
131
|
+
const sorted = [...bombs].sort((a, b) => a.tick - b.tick);
|
|
132
|
+
const planted = sorted.find((bomb) => bomb.type === "planted");
|
|
133
|
+
const terminal = sorted.find((bomb) => bomb.type === "exploded" || bomb.type === "defused");
|
|
134
|
+
if (terminal && (!planted || planted.tick > terminal.tick)) {
|
|
135
|
+
issues.push({
|
|
136
|
+
severity: "error",
|
|
137
|
+
code: "bomb.lifecycle_without_plant",
|
|
138
|
+
message: `Round ${roundNumber} has ${terminal.type} before any planted event.`,
|
|
139
|
+
path: "bombs"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const spatialRows = pkg.kills.filter((kill) => kill.victimPosition.x !== 0 || kill.victimPosition.y !== 0).length;
|
|
145
|
+
if (pkg.kills.length > 0 && spatialRows === 0) {
|
|
146
|
+
issues.push({
|
|
147
|
+
severity: "warning",
|
|
148
|
+
code: "spatial.no_real_kill_positions",
|
|
149
|
+
message: "Kills exist, but no non-origin victim positions were found.",
|
|
150
|
+
path: "kills"
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
155
|
+
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: errorCount === 0,
|
|
159
|
+
summary: {
|
|
160
|
+
issueCount: issues.length,
|
|
161
|
+
errorCount,
|
|
162
|
+
warningCount
|
|
163
|
+
},
|
|
164
|
+
issues
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function fieldAvailability(pkg: DemoPackage): ScoreboardFieldAvailability {
|
|
169
|
+
return {
|
|
170
|
+
playerStats: pkg.playerStats.length > 0 ? "available" : "missing",
|
|
171
|
+
economy: pkg.playerEconomies.length > 0 ? "available" : "missing",
|
|
172
|
+
rounds: pkg.rounds.length > 0 ? "available" : "missing",
|
|
173
|
+
richKills: richKillAvailability(pkg),
|
|
174
|
+
damages: pkg.damages.length > 0 ? "available" : "missing",
|
|
175
|
+
bombs: pkg.bombs.length > 0 ? "available" : "missing"
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function fieldConfidence(availability: ScoreboardFieldAvailability): number {
|
|
180
|
+
const values = [
|
|
181
|
+
availability.playerStats,
|
|
182
|
+
availability.economy,
|
|
183
|
+
availability.rounds,
|
|
184
|
+
availability.richKills,
|
|
185
|
+
availability.damages,
|
|
186
|
+
availability.bombs
|
|
187
|
+
].map<number>((value) => value === "available" ? 1 : value === "partial" ? 0.5 : 0);
|
|
188
|
+
return round(values.reduce((sum, value) => sum + value, 0) / values.length, 3);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function richKillAvailability(pkg: DemoPackage): ScoreboardFieldAvailability["richKills"] {
|
|
192
|
+
if (pkg.kills.length === 0) return "missing";
|
|
193
|
+
const hasFlags = pkg.kills.some((kill) => "throughSmoke" in kill && "noScope" in kill && "penetratedObjects" in kill);
|
|
194
|
+
const activeWeaponsAreNames = pkg.kills.some((kill) => kill.killerActiveWeapon && isNamedWeapon(normalizeWeapon(kill.killerActiveWeapon)));
|
|
195
|
+
if (hasFlags && activeWeaponsAreNames) return "available";
|
|
196
|
+
if (hasFlags) return "partial";
|
|
197
|
+
return "missing";
|
|
198
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeRR,
|
|
3
|
+
computePrism,
|
|
4
|
+
rrWeightsV1,
|
|
5
|
+
prismWeightsV1,
|
|
6
|
+
rrToPercentile,
|
|
7
|
+
type RRResult,
|
|
8
|
+
type RRWeights,
|
|
9
|
+
type PrismWeights,
|
|
10
|
+
type PrismComputeInput
|
|
11
|
+
} from "@rivalhub/rival-rating";
|
|
12
|
+
import type {
|
|
13
|
+
AccountSignalsV2,
|
|
14
|
+
DemoPackage,
|
|
15
|
+
PlayerIndicatorRow,
|
|
16
|
+
PlayerRoundFact,
|
|
17
|
+
PlayerScoreboardRow,
|
|
18
|
+
RRIndicators
|
|
19
|
+
} from "@cs2dak/contract";
|
|
20
|
+
import type { RRResultV2 } from "@rivalhub/rival-rating";
|
|
21
|
+
import { normalizeDemoPackage } from "./normalize.js";
|
|
22
|
+
import { round, firstKillMap, clutchSplit, isUtilityWeapon, killWeaponName } from "./utils.js";
|
|
23
|
+
import { fieldAvailability, fieldConfidence } from "./qa.js";
|
|
24
|
+
|
|
25
|
+
export function deriveRRIndicators(input: unknown): RRIndicators[] {
|
|
26
|
+
const pkg = normalizeDemoPackage(input);
|
|
27
|
+
return buildPlayerIndicators(pkg, buildPlayerRoundFacts(pkg)).map((row) => row.indicators);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildPlayerRoundFacts(pkg: DemoPackage): PlayerRoundFact[] {
|
|
31
|
+
const firstKillByRound = firstKillMap(pkg);
|
|
32
|
+
|
|
33
|
+
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);
|
|
41
|
+
const side = player.teamKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
|
|
42
|
+
const firstKill = firstKillByRound.get(roundRow.roundNumber);
|
|
43
|
+
const kastTags = new Set<PlayerRoundFact["kastTags"][number]>();
|
|
44
|
+
|
|
45
|
+
if (kills.length > 0) kastTags.add("kill");
|
|
46
|
+
if (assists.length > 0 || flashAssists.length > 0) kastTags.add("assist");
|
|
47
|
+
if (deaths.length === 0) kastTags.add("survive");
|
|
48
|
+
if (deaths.some((death) => death.tradeDeath)) kastTags.add("trade");
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
roundNumber: roundRow.roundNumber,
|
|
52
|
+
steamId64: player.steamId64,
|
|
53
|
+
name: player.name,
|
|
54
|
+
teamKey: player.teamKey,
|
|
55
|
+
side,
|
|
56
|
+
survived: deaths.length === 0,
|
|
57
|
+
kills: kills.length,
|
|
58
|
+
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,
|
|
63
|
+
tradeKills: kills.filter((kill) => kill.tradeKill).length,
|
|
64
|
+
tradedDeaths: deaths.filter((death) => death.tradeDeath).length,
|
|
65
|
+
openingDuel: firstKill?.killerSteamId64 === player.steamId64 ? "won" : firstKill?.victimSteamId64 === player.steamId64 ? "lost" : "none",
|
|
66
|
+
kastTags: [...kastTags],
|
|
67
|
+
equipmentValue: economy?.equipmentValue ?? null,
|
|
68
|
+
economyType: economy?.type ?? null
|
|
69
|
+
};
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
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);
|
|
77
|
+
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);
|
|
83
|
+
const totalRounds = Math.max(playerFacts.length, 1);
|
|
84
|
+
const killsByRound = new Map<number, number>();
|
|
85
|
+
for (const kill of playerKills) {
|
|
86
|
+
killsByRound.set(kill.roundNumber, (killsByRound.get(kill.roundNumber) ?? 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
const mkRounds = [...killsByRound.values()];
|
|
89
|
+
const firstKillCount = stats?.firstKillCount ?? playerFacts.filter((fact) => fact.openingDuel === "won").length;
|
|
90
|
+
const firstDeathCount = stats?.firstDeathCount ?? playerFacts.filter((fact) => fact.openingDuel === "lost").length;
|
|
91
|
+
const openingDuels = firstKillCount + firstDeathCount;
|
|
92
|
+
const awpKills = playerKills.filter((kill) => killWeaponName(kill) === "awp").length;
|
|
93
|
+
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;
|
|
103
|
+
const deaths = stats?.deaths ?? playerDeaths.length;
|
|
104
|
+
const kills = stats?.kills ?? playerKills.length;
|
|
105
|
+
const assists = stats?.assists ?? playerFacts.reduce((sum, fact) => sum + fact.assists, 0);
|
|
106
|
+
const damage = stats?.damageHealth ?? playerFacts.reduce((sum, fact) => sum + fact.damage, 0);
|
|
107
|
+
const tradeKillCount = stats?.tradeKillCount ?? playerFacts.reduce((sum, fact) => sum + fact.tradeKills, 0);
|
|
108
|
+
const tradeDeathCount = stats?.tradeDeathCount ?? playerFacts.reduce((sum, fact) => sum + fact.tradedDeaths, 0);
|
|
109
|
+
const playedRounds = Math.max(stats?.rounds ?? totalRounds, 1);
|
|
110
|
+
const clutchWins = stats
|
|
111
|
+
? stats.vsOneWonCount + stats.vsTwoWonCount + stats.vsThreeWonCount + stats.vsFourWonCount + stats.vsFiveWonCount
|
|
112
|
+
: playerClutches.filter((row) => row.won).length;
|
|
113
|
+
const clutchScore = stats
|
|
114
|
+
? stats.vsOneWonCount + stats.vsTwoWonCount * 2 + stats.vsThreeWonCount * 3 + stats.vsFourWonCount * 4 + stats.vsFiveWonCount * 5
|
|
115
|
+
: playerClutches.reduce((sum, row) => sum + (row.won ? row.opponentCount : 0), 0);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
steamId64: player.steamId64,
|
|
119
|
+
totalRounds: playedRounds,
|
|
120
|
+
kills,
|
|
121
|
+
deaths,
|
|
122
|
+
assists,
|
|
123
|
+
kpr: round(kills / playedRounds, 4),
|
|
124
|
+
dpr: round(deaths / playedRounds, 4),
|
|
125
|
+
apr: round(assists / playedRounds, 4),
|
|
126
|
+
adr: round(stats?.adr ?? damage / playedRounds, 2),
|
|
127
|
+
hsPercent: kills > 0 ? round(((stats?.headshotCount ?? playerKills.filter((kill) => kill.headshot).length) / kills) * 100, 2) : 0,
|
|
128
|
+
kast: round(stats?.kast ?? (playerFacts.filter((fact) => fact.kastTags.length > 0).length / playedRounds) * 100, 2),
|
|
129
|
+
survivalRate: round(Math.max(0, playedRounds - deaths) / playedRounds, 4),
|
|
130
|
+
twoKillRounds: stats?.twoKillCount ?? mkRounds.filter((count) => count === 2).length,
|
|
131
|
+
threeKillRounds: stats?.threeKillCount ?? mkRounds.filter((count) => count === 3).length,
|
|
132
|
+
fourKillRounds: stats?.fourKillCount ?? mkRounds.filter((count) => count === 4).length,
|
|
133
|
+
fiveKillRounds: stats?.fiveKillCount ?? mkRounds.filter((count) => count >= 5).length,
|
|
134
|
+
multiKillRate: round((stats ? stats.twoKillCount + stats.threeKillCount + stats.fourKillCount + stats.fiveKillCount : mkRounds.filter((count) => count >= 2).length) / playedRounds, 4),
|
|
135
|
+
firstKillCount,
|
|
136
|
+
firstDeathCount,
|
|
137
|
+
firstKillRate: round(firstKillCount / playedRounds, 4),
|
|
138
|
+
firstDeathRate: round(firstDeathCount / playedRounds, 4),
|
|
139
|
+
openingDuelRate: round(openingDuels / playedRounds, 4),
|
|
140
|
+
openingDuelWinRate: openingDuels > 0 ? round(firstKillCount / openingDuels, 4) : 0,
|
|
141
|
+
tradeKillCount,
|
|
142
|
+
tradeDeathCount,
|
|
143
|
+
tradeKillRate: round(tradeKillCount / playedRounds, 4),
|
|
144
|
+
tradeDeathRate: deaths > 0 ? round(tradeDeathCount / deaths, 4) : 0,
|
|
145
|
+
clutchAttempts: playerClutches.length,
|
|
146
|
+
clutchWins,
|
|
147
|
+
clutchWinRate: playerClutches.length > 0 ? round(clutchWins / playerClutches.length, 4) : 0,
|
|
148
|
+
clutchFrequency: round(playerClutches.length / playedRounds, 4),
|
|
149
|
+
clutchScore,
|
|
150
|
+
clutchScoreRate: round(clutchScore / playedRounds, 4),
|
|
151
|
+
vsOne: clutchSplit(stats?.vsOneCount, stats?.vsOneWonCount, playerClutches, 1),
|
|
152
|
+
vsTwo: clutchSplit(stats?.vsTwoCount, stats?.vsTwoWonCount, playerClutches, 2),
|
|
153
|
+
vsThree: clutchSplit(stats?.vsThreeCount, stats?.vsThreeWonCount, playerClutches, 3),
|
|
154
|
+
vsFour: clutchSplit(stats?.vsFourCount, stats?.vsFourWonCount, playerClutches, 4),
|
|
155
|
+
vsFive: clutchSplit(stats?.vsFiveCount, stats?.vsFiveWonCount, playerClutches, 5),
|
|
156
|
+
awpKills,
|
|
157
|
+
awpKillsPerRound: round(awpKills / playedRounds, 4),
|
|
158
|
+
awpKillRate: kills > 0 ? round(awpKills / kills, 4) : 0,
|
|
159
|
+
sniperKills,
|
|
160
|
+
sniperKillRate: kills > 0 ? round(sniperKills / kills, 4) : 0,
|
|
161
|
+
awpMultiKillRate: null,
|
|
162
|
+
awpDuelWinRate: null,
|
|
163
|
+
utilityDamage,
|
|
164
|
+
utilityDamagePerRound: round(stats?.averageUtilityDamagePerRound ?? utilityDamage / playedRounds, 2),
|
|
165
|
+
flashAssistCount,
|
|
166
|
+
flashAssistPerRound: round(flashAssistCount / playedRounds, 4),
|
|
167
|
+
blindDurationTotal: round(enemyFlashDurationSeconds, 2),
|
|
168
|
+
blindDurationPerRound: round(enemyFlashDurationSeconds / playedRounds, 2),
|
|
169
|
+
enemyFlashDurationSeconds: round(enemyFlashDurationSeconds, 2),
|
|
170
|
+
enemyFlashDurationPerRound: round(enemyFlashDurationSeconds / playedRounds, 2),
|
|
171
|
+
teamFlashDurationSeconds: round(teamFlashDurationSeconds, 2),
|
|
172
|
+
teamFlashDurationPerRound: round(teamFlashDurationSeconds / playedRounds, 2),
|
|
173
|
+
grenadeCount,
|
|
174
|
+
grenadeCountPerRound: round(grenadeCount / playedRounds, 4),
|
|
175
|
+
ecoRoundCount: playerEconomies.filter((row) => row.type === "eco").length,
|
|
176
|
+
forceRoundCount: playerEconomies.filter((row) => row.type === "force").length,
|
|
177
|
+
fullBuyRoundCount: playerEconomies.filter((row) => row.type === "full").length,
|
|
178
|
+
pistolRoundCount: playerEconomies.filter((row) => row.type === "pistol").length,
|
|
179
|
+
avgEquipmentValue: playerEconomies.length > 0 ? round(playerEconomies.reduce((sum, row) => sum + row.equipmentValue, 0) / playerEconomies.length, 2) : 0,
|
|
180
|
+
combatDeathCount: stats?.combatDeathCount ?? deaths,
|
|
181
|
+
bombDeathCount: stats?.bombDeathCount ?? null,
|
|
182
|
+
wallbangKillCount: stats?.wallbangKillCount ?? playerKills.filter((kill) => (kill.penetratedObjects ?? 0) > 0).length,
|
|
183
|
+
roundSwingTotal: null,
|
|
184
|
+
roundSwingPerKill: null
|
|
185
|
+
} satisfies RRIndicators;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const rrWeights = rrWeightsV1 as unknown as RRWeights;
|
|
189
|
+
const prismWeights = prismWeightsV1 as unknown as PrismWeights;
|
|
190
|
+
const rrResults = indicators.map((indicator) => computeRR(indicator, rrWeights));
|
|
191
|
+
const rrScores = rrResults.map((result) => result.rr);
|
|
192
|
+
const prismInputs: PrismComputeInput[] = indicators.map((indicator, index) => ({
|
|
193
|
+
indicators: indicator,
|
|
194
|
+
mapCount: 1,
|
|
195
|
+
rrPercentile: round(rrToPercentile(rrScores, rrResults[index]?.rr ?? 1), 1)
|
|
196
|
+
}));
|
|
197
|
+
const prismResults = computePrism(prismInputs, prismWeights);
|
|
198
|
+
|
|
199
|
+
return indicators.map((indicator, index) => {
|
|
200
|
+
const player = pkg.players.find((row) => row.steamId64 === indicator.steamId64);
|
|
201
|
+
return {
|
|
202
|
+
steamId64: indicator.steamId64,
|
|
203
|
+
name: player?.name ?? indicator.steamId64,
|
|
204
|
+
teamKey: player?.teamKey ?? "teamA",
|
|
205
|
+
indicators: indicator,
|
|
206
|
+
rr: rrResults[index] ?? zeroRR(rrWeights.version),
|
|
207
|
+
rrPercentile: prismInputs[index]?.rrPercentile ?? 50,
|
|
208
|
+
prism: prismResults.find((result) => result.steamId64 === indicator.steamId64) ?? null
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function buildScoreboard(
|
|
214
|
+
pkg: DemoPackage,
|
|
215
|
+
rows: PlayerIndicatorRow[],
|
|
216
|
+
accountRatings: Array<{ signals: AccountSignalsV2; rr: RRResultV2 }>
|
|
217
|
+
): PlayerScoreboardRow[] {
|
|
218
|
+
const accountBySteamId = new Map(accountRatings.map((row) => [row.signals.steamId64, row]));
|
|
219
|
+
const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
|
|
220
|
+
const availability = fieldAvailability(pkg);
|
|
221
|
+
const confidence = fieldConfidence(availability);
|
|
222
|
+
return rows.map((row) => {
|
|
223
|
+
const account = accountBySteamId.get(row.steamId64);
|
|
224
|
+
const accountRr = account?.rr;
|
|
225
|
+
const combatSignals = account?.signals.combat;
|
|
226
|
+
const stats = statsBySteamId.get(row.steamId64);
|
|
227
|
+
const playerKills = pkg.kills.filter((kill) => kill.killerSteamId64 === row.steamId64);
|
|
228
|
+
return {
|
|
229
|
+
steamId64: row.steamId64,
|
|
230
|
+
name: row.name,
|
|
231
|
+
teamKey: row.teamKey,
|
|
232
|
+
kills: row.indicators.kills,
|
|
233
|
+
deaths: row.indicators.deaths,
|
|
234
|
+
assists: row.indicators.assists,
|
|
235
|
+
adr: round(row.indicators.adr, 1),
|
|
236
|
+
kast: round(row.indicators.kast, 1),
|
|
237
|
+
headshotPercent: round(row.indicators.hsPercent, 1),
|
|
238
|
+
entryKills: row.indicators.firstKillCount,
|
|
239
|
+
tradeKills: row.indicators.tradeKillCount,
|
|
240
|
+
awpKills: row.indicators.awpKills,
|
|
241
|
+
utilityDamage: row.indicators.utilityDamage,
|
|
242
|
+
combatDeathCount: row.indicators.combatDeathCount,
|
|
243
|
+
bombDeathCount: row.indicators.bombDeathCount,
|
|
244
|
+
wallbangKillCount: row.indicators.wallbangKillCount,
|
|
245
|
+
noScopeKillCount: stats?.noScopeKillCount ?? playerKills.filter((kill) => kill.noScope).length,
|
|
246
|
+
throughSmokeKillCount: playerKills.filter((kill) => kill.throughSmoke).length,
|
|
247
|
+
collateralKillCount: stats?.collateralKillCount ?? null,
|
|
248
|
+
bombPlantCount: stats?.bombPlantCount ?? null,
|
|
249
|
+
bombDefuseCount: stats?.bombDefuseCount ?? null,
|
|
250
|
+
confidence,
|
|
251
|
+
fieldAvailability: availability,
|
|
252
|
+
ratingSeed: round(row.rr.rrBase, 2),
|
|
253
|
+
rr: round(row.rr.rr, 2),
|
|
254
|
+
rrPercentile: round(row.rrPercentile, 1),
|
|
255
|
+
accountRR: round(accountRr?.rr ?? 0, 3),
|
|
256
|
+
accountRRRaw: round(accountRr?.rrRaw ?? 0, 3),
|
|
257
|
+
accountCombatContextFactor: round(accountRr?.combatContextFactor ?? 1, 3),
|
|
258
|
+
accountBreakdown: {
|
|
259
|
+
combat: round(accountRr?.accounts.combat ?? 0, 4),
|
|
260
|
+
trade: round(accountRr?.accounts.trade ?? 0, 4),
|
|
261
|
+
clutch: round(accountRr?.accounts.clutch ?? 0, 4),
|
|
262
|
+
objective: round(accountRr?.accounts.objective ?? 0, 4),
|
|
263
|
+
utility: round(accountRr?.accounts.utility ?? 0, 4)
|
|
264
|
+
},
|
|
265
|
+
accountContextStatus: {
|
|
266
|
+
buyDelta: (combatSignals?.killsByBuyDelta == null ? "missing" : "available") as "available" | "missing",
|
|
267
|
+
manState: (combatSignals?.killsByManState == null ? "missing" : "available") as "available" | "missing"
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}).sort((a, b) => b.accountRR - a.accountRR || b.rr - a.rr || b.adr - a.adr);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function zeroRR(version: string): RRResult {
|
|
274
|
+
return {
|
|
275
|
+
rr: 1,
|
|
276
|
+
rrBase: 1,
|
|
277
|
+
rrSwing: 0,
|
|
278
|
+
weightsVersion: version,
|
|
279
|
+
breakdown: {
|
|
280
|
+
kastTerm: 0,
|
|
281
|
+
kprTerm: 0,
|
|
282
|
+
dprTerm: 0,
|
|
283
|
+
impactTerm: 0,
|
|
284
|
+
adrTerm: 0,
|
|
285
|
+
intercept: 0
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|