@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/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
+ }