@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/signals.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
computeFrozenProBaselineRR,
|
|
3
|
+
computeRRSixAccounts,
|
|
4
|
+
rrSixAccountProBaselineV0,
|
|
5
|
+
rrSixAccountWeightsV1
|
|
5
6
|
} from "@rivalhub/rival-rating";
|
|
6
|
-
import type {
|
|
7
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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,
|
|
57
|
-
openingKills: stats?.firstKillCount ?? openingKillsForPlayer(pkg,
|
|
58
|
-
openingDeaths: stats?.firstDeathCount ?? openingDeathsForPlayer(pkg,
|
|
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:
|
|
78
|
-
vsTwo:
|
|
79
|
-
vsThree:
|
|
80
|
-
vsFour:
|
|
81
|
-
vsFive:
|
|
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:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
118
|
+
} satisfies RRSignals;
|
|
91
119
|
});
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
export function computeAccountRatingsV2(input: unknown): Array<{ signals:
|
|
95
|
-
const weights =
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
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 (
|
|
118
|
-
const killerEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.
|
|
119
|
-
const victimEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.
|
|
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
|
|
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
|
|
139
|
-
|
|
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
|
|
145
|
-
|
|
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.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
189
|
-
const
|
|
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 (
|
|
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
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
}
|