@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/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,38 +29,44 @@ 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 {
49
60
  steamId64: player.steamId64,
50
61
  rounds,
51
- sourceVersion: "cs2-demo-analysis-kit/0.3",
62
+ sourceVersion: "cs2-demo-analysis-kit/1.0",
52
63
  combat: {
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
  }
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Replay } from "@cs2dak/contract";
3
+ import type { MapZones } from "@cs2dak/maps";
4
+ import { annotatePositions, groupSamplesByRoundTick, loadSpatialAssets, type SpatialAssets } from "./annotate.js";
5
+
6
+ function deltaArr(values: number[]): number[] {
7
+ const out: number[] = [];
8
+ let prev = 0;
9
+ for (const v of values) { out.push(v - prev); prev = v; }
10
+ return out;
11
+ }
12
+
13
+ /** Build a replay round with one (or more) player tracks at a single tick. */
14
+ function replayRound(
15
+ tick: number,
16
+ tracks: { playerIdx: number; x: number; y: number; z: number; placeIdx: number }[],
17
+ ): Replay["rounds"][number] {
18
+ return {
19
+ roundNumber: 1,
20
+ startTick: tick,
21
+ tickStep: 8,
22
+ frameCount: 1,
23
+ players: tracks.map((t) => ({
24
+ playerIndex: t.playerIdx,
25
+ x: deltaArr([t.x]), y: deltaArr([t.y]), z: deltaArr([t.z]),
26
+ yaw: deltaArr([0]), pitch: deltaArr([0]),
27
+ hp: [100], armor: [100], money: [800], equipValue: [800],
28
+ place: [t.placeIdx], flash: [0],
29
+ flags: [1],
30
+ weapon: [-1],
31
+ grenades: [[]],
32
+ })),
33
+ projectiles: [],
34
+ };
35
+ }
36
+
37
+ function pkgWith(mapName: string, rows: { place: string; x: number; y: number; z: number }[]): DemoPackage {
38
+ const placeDict = [...new Set(rows.map((r) => r.place))];
39
+ const placeMap = new Map(placeDict.map((p, i) => [p, i]));
40
+ const tracks = rows.map((r, i) => ({
41
+ playerIdx: Math.min(i, 1), // player 0 or 1
42
+ x: r.x, y: r.y, z: r.z,
43
+ placeIdx: placeMap.get(r.place) ?? -1,
44
+ }));
45
+ return {
46
+ match: { mapName, tickrate: 64 },
47
+ players: [
48
+ { steamId64: "T1", teamKey: "teamA" },
49
+ rows.length > 1 ? { steamId64: "T2", teamKey: "teamA" } : { steamId64: "T2", teamKey: "teamA" },
50
+ ],
51
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
52
+ replay: {
53
+ meta: { sampleRate: 8, tickrate: 64, coordScale: 1, angleScale: 10 },
54
+ weaponDict: [],
55
+ placeDict,
56
+ rounds: [replayRound(120, tracks)],
57
+ },
58
+ } as unknown as DemoPackage;
59
+ }
60
+
61
+ /** Create a package with no replay data (simulates unobservable positions). */
62
+ function pkgWithoutReplay(): DemoPackage {
63
+ return {
64
+ match: { mapName: "de_dust2", tickrate: 64 },
65
+ players: [{ steamId64: "T1", teamKey: "teamA" }],
66
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
67
+ } as unknown as DemoPackage;
68
+ }
69
+
70
+ describe("loadSpatialAssets", () => {
71
+ it("reports availability per asset for an active-duty map", () => {
72
+ const assets = loadSpatialAssets("de_dust2");
73
+ expect(assets.available.routes).toBe(true);
74
+ expect(assets.available.nav).toBe(true);
75
+ expect(assets.available.visibility).toBe(false); // no .tri passed
76
+ });
77
+
78
+ it("reports everything missing for an unknown map", () => {
79
+ const assets = loadSpatialAssets("de_unknown");
80
+ expect(assets.available.routes).toBe(false);
81
+ expect(assets.available.nav).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("annotatePositions", () => {
86
+ it("attaches callout and position from replay when nav is available", () => {
87
+ const assets = loadSpatialAssets("de_dust2");
88
+ const samples = annotatePositions(pkgWith("de_dust2", [{ place: "LongA", x: 760, y: 1660, z: 0 }]), assets);
89
+ expect(samples).toHaveLength(1);
90
+ expect(samples[0]!.callout).toBe("LongA");
91
+ expect(samples[0]!.position).toEqual({ x: 760, y: 1660, z: 0 });
92
+ });
93
+
94
+ it("leaves navAreaId null when nav is unavailable", () => {
95
+ const assets = loadSpatialAssets("de_unknown");
96
+ const samples = annotatePositions(pkgWith("de_unknown", [{ place: "Somewhere", x: 0, y: 0, z: 0 }]), assets);
97
+ expect(samples).toHaveLength(1);
98
+ expect(samples[0]!.navAreaId).toBeNull();
99
+ expect(samples[0]!.callout).toBe("Somewhere");
100
+ });
101
+
102
+ it("returns an empty array without positions", () => {
103
+ const assets = loadSpatialAssets("de_dust2");
104
+ expect(annotatePositions(pkgWithoutReplay(), assets)).toEqual([]);
105
+ });
106
+
107
+ it("groups samples by round then tick", () => {
108
+ const assets = loadSpatialAssets("de_dust2");
109
+ const samples = annotatePositions(
110
+ pkgWith("de_dust2", [{ place: "LongA", x: 760, y: 1660, z: 0 }, { place: "TSpawn", x: -500, y: -850, z: 96 }]),
111
+ assets,
112
+ );
113
+ const grouped = groupSamplesByRoundTick(samples);
114
+ expect(grouped.get(1)!.get(120)!).toHaveLength(2);
115
+ });
116
+
117
+ it("populates zoneId/zoneRole/zoneBombsite via zoneAt when zones are available", () => {
118
+ const TEST_ZONES: MapZones = {
119
+ mapName: "de_test",
120
+ version: "t",
121
+ zones: [{ id: "a_site", name: "A", role: "site", bombsite: "a", polygon: [[0, 0], [100, 0], [100, 100], [0, 100]] }],
122
+ };
123
+ const assets: SpatialAssets = {
124
+ mapName: "de_test", routes: null, zones: TEST_ZONES, nav: null, visibility: null,
125
+ available: { routes: false, zones: true, nav: false, visibility: false },
126
+ };
127
+ const samples = annotatePositions(pkgWith("de_test", [{ place: "A", x: 50, y: 50, z: 0 }]), assets);
128
+ expect(samples).toHaveLength(1);
129
+ expect(samples[0]!.zoneId).toBe("a_site");
130
+ expect(samples[0]!.zoneRole).toBe("site");
131
+ expect(samples[0]!.zoneBombsite).toBe("a");
132
+ });
133
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Raw evidence 地基:位置标注 + 空间资产装载(严格重建 SP1)。
3
+ * 设计见 docs/design/rr-model.md §3、§5(计算流程)。
4
+ *
5
+ * 当前控图/战术语义真相源是 replay place 生成的 callout grid,不是手标 zone polygon。
6
+ * zone 字段只保留给旧 shadow spatial/utility 几何路径;新增控图 finding 不应消费 zoneId。
7
+ * 这些是 official MapControl gate(SP2:solo pressure 的 nav 距离、denial 的 LOS)的输入底座。
8
+ *
9
+ * v3 replay 8Hz 流替代了 v2 的 positions-1s。每帧解码后转换:
10
+ * - track.tick(差分编码 → decodeDelta)
11
+ * - track.x/y/z(差分编码 × coordScale → 游戏单位)
12
+ * - track.place(索引 → placeDict)
13
+ * - track.flags(FLAG_ALIVE)
14
+ * - track.hp(原始值)
15
+ */
16
+ import type { DemoPackage } from "@cs2dak/contract";
17
+ import { decodeDelta, FLAG_ALIVE } from "@cs2dak/contract";
18
+ import { createResolverFromPackage } from "../resolve.js";
19
+ import {
20
+ getMapNav,
21
+ getMapRoutes,
22
+ getMapZones,
23
+ zoneAt,
24
+ type CompactNav,
25
+ type MapRoutes,
26
+ type MapZones,
27
+ type TriangleBvh,
28
+ type Vec3,
29
+ type ZoneRole,
30
+ } from "@cs2dak/maps";
31
+
32
+ export interface SpatialAssets {
33
+ mapName: string;
34
+ routes: MapRoutes | null;
35
+ zones: MapZones | null;
36
+ nav: CompactNav | null;
37
+ /** 静态视线 BVH;Node 下经 tri-assets 装载,浏览器降级为 null。 */
38
+ visibility: TriangleBvh | null;
39
+ available: {
40
+ routes: boolean;
41
+ zones: boolean;
42
+ nav: boolean;
43
+ visibility: boolean;
44
+ };
45
+ }
46
+
47
+ export function loadSpatialAssets(mapName: string, triBvh?: TriangleBvh | null): SpatialAssets {
48
+ const routes = getMapRoutes(mapName);
49
+ const zones = getMapZones(mapName);
50
+ const nav = getMapNav(mapName);
51
+ const visibility = triBvh ?? null;
52
+ return {
53
+ mapName,
54
+ routes,
55
+ zones,
56
+ nav,
57
+ visibility,
58
+ available: {
59
+ routes: routes != null,
60
+ zones: zones != null,
61
+ nav: nav != null,
62
+ visibility: visibility != null,
63
+ },
64
+ };
65
+ }
66
+
67
+ export interface AnnotatedSample {
68
+ roundNumber: number;
69
+ tick: number;
70
+ steamId64: string;
71
+ teamKey: string;
72
+ side: string | null;
73
+ alive: boolean;
74
+ position: Vec3;
75
+ /** callout(replay placeDict);缺失为 null。 */
76
+ callout: string | null;
77
+ /** legacy 手标 zone id;当前控图/战术语义不要把它当真相源。 */
78
+ zoneId: string | null;
79
+ /** zone 语义角色(site/mid/connector/lane…);无 zone 为 null。 */
80
+ zoneRole: ZoneRole | null;
81
+ /** zone 关联包点;无则 null。 */
82
+ zoneBombsite: "a" | "b" | null;
83
+ /** 最近 nav area id;无 nav 资产为 null。 */
84
+ navAreaId: number | null;
85
+ /** 当前血量(火焰逼退掉血判定用);缺失为 100。 */
86
+ health: number;
87
+ }
88
+
89
+ /**
90
+ * 从 replay 8Hz 流标注位置样本(v3 替代 v2 positions-1s)。
91
+ * 返回所有存活/阵亡玩家的逐帧标注,zone/nav 判定暂缺(需 maps 包导出 zoneAt/nearestNavArea)。
92
+ */
93
+ export function annotatePositions(pkg: DemoPackage, assets: SpatialAssets): AnnotatedSample[] {
94
+ const resolver = createResolverFromPackage(pkg);
95
+ const replay = pkg.replay;
96
+ if (!replay) return [];
97
+
98
+ const coordScale = replay.meta.coordScale;
99
+ const placeDict = replay.placeDict ?? [];
100
+ const samples: AnnotatedSample[] = [];
101
+
102
+ const roundByNumber = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
103
+
104
+ for (const round of replay.rounds) {
105
+ const roundRow = roundByNumber.get(round.roundNumber);
106
+ if (!roundRow) continue;
107
+ const tickStep = round.tickStep;
108
+
109
+ for (const track of round.players) {
110
+ const player = resolver.byIndexOrNull(track.playerIndex);
111
+ if (!player) continue;
112
+
113
+ const xs = decodeDelta(track.x);
114
+ const ys = decodeDelta(track.y);
115
+ const zs = decodeDelta(track.z);
116
+ const side: string | null =
117
+ player.teamKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
118
+
119
+ for (let i = 0; i < xs.length; i++) {
120
+ const placeIdx = track.place[i] ?? -1;
121
+ const px = (xs[i] ?? 0) * coordScale;
122
+ const py = (ys[i] ?? 0) * coordScale;
123
+ const pz = (zs[i] ?? 0) * coordScale;
124
+ const zone = assets.zones ? zoneAt(assets.zones, px, py, pz) : null;
125
+ samples.push({
126
+ roundNumber: round.roundNumber,
127
+ tick: round.startTick + i * tickStep,
128
+ steamId64: player.steamId64,
129
+ teamKey: player.teamKey,
130
+ side,
131
+ alive: ((track.flags[i] ?? 0) & FLAG_ALIVE) !== 0,
132
+ position: { x: px, y: py, z: pz },
133
+ callout: placeIdx >= 0 && placeIdx < placeDict.length ? placeDict[placeIdx] : null,
134
+ zoneId: zone?.id ?? null,
135
+ zoneRole: (zone?.role as ZoneRole | undefined) ?? null,
136
+ zoneBombsite: (zone?.bombsite as "a" | "b" | undefined) ?? null,
137
+ navAreaId: null,
138
+ health: track.hp[i] ?? 100,
139
+ });
140
+ }
141
+ }
142
+ }
143
+
144
+ return samples;
145
+ }
146
+
147
+ /** 按 (round, tick) 分组的标注样本,供逐帧 gate 消费。 */
148
+ export function groupSamplesByRoundTick(
149
+ samples: AnnotatedSample[],
150
+ ): Map<number, Map<number, AnnotatedSample[]>> {
151
+ const out = new Map<number, Map<number, AnnotatedSample[]>>();
152
+ for (const s of samples) {
153
+ let byTick = out.get(s.roundNumber);
154
+ if (!byTick) {
155
+ byTick = new Map();
156
+ out.set(s.roundNumber, byTick);
157
+ }
158
+ const arr = byTick.get(s.tick) ?? [];
159
+ arr.push(s);
160
+ byTick.set(s.tick, arr);
161
+ }
162
+ return out;
163
+ }