@cs2dak/core 1.0.0 → 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 +46 -53
- package/src/index.ts +25 -8
- 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 +27 -1
- 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/signals.ts +149 -112
- 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 +4 -4
- package/src/economy.test.ts +0 -37
- package/src/economy.ts +0 -57
package/src/duels.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import type { DemoPackage, DuelWindow, PackageDamage, PackageKill, PackageShots, ReplayPlayerTrack, Vec3 } from "@cs2dak/contract";
|
|
2
|
+
import type { TriangleBvh } from "@cs2dak/maps";
|
|
3
|
+
import { decodeDelta } from "@cs2dak/contract";
|
|
4
|
+
import { decodeDuelWindow, frameIndexForTick, isVisibleAt, type VisibilityContext } from "./duel-window.js";
|
|
5
|
+
import { createResolverFromPackage, type PlayerResolver } from "./resolve.js";
|
|
6
|
+
import { activeDamages, killWeaponName, normalizeWeapon, round } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
const ENGAGEMENT_GAP_SECONDS = 1.5;
|
|
9
|
+
const CONTESTED_WINDOW_SECONDS = 1.5;
|
|
10
|
+
const DUEL_PAIR_WINDOW_SECONDS = 2;
|
|
11
|
+
const FULL_HEALTH_HP = 80;
|
|
12
|
+
const SUPPRESSED_ANGLE_DEGREES = 60;
|
|
13
|
+
const BURST_GAP_SECONDS = 0.25;
|
|
14
|
+
const RUNNING_SPEED_THRESHOLD = 120;
|
|
15
|
+
|
|
16
|
+
export type DuelClassification = "contested_duel" | "suppressed_kill" | "caught_off_guard";
|
|
17
|
+
export type DuelHpBucket = "full_hp" | "low_hp";
|
|
18
|
+
|
|
19
|
+
export interface DuelEvidenceTicks {
|
|
20
|
+
engagementStartTick: number;
|
|
21
|
+
engagementEndTick: number;
|
|
22
|
+
killerFirstShotTick: number | null;
|
|
23
|
+
victimResponseTick: number | null;
|
|
24
|
+
killTick: number;
|
|
25
|
+
windowStartTick?: number;
|
|
26
|
+
windowEndTick?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DuelRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
roundNumber: number;
|
|
32
|
+
tick: number;
|
|
33
|
+
engagementId: string;
|
|
34
|
+
duelPairId: string;
|
|
35
|
+
killerSteamId64: string;
|
|
36
|
+
victimSteamId64: string;
|
|
37
|
+
killerName: string;
|
|
38
|
+
victimName: string;
|
|
39
|
+
killerTeamKey: string;
|
|
40
|
+
victimTeamKey: string;
|
|
41
|
+
killerIndex: number;
|
|
42
|
+
victimIndex: number;
|
|
43
|
+
weapon: string;
|
|
44
|
+
headshot: boolean;
|
|
45
|
+
throughSmoke: boolean;
|
|
46
|
+
penetratedObjects: number;
|
|
47
|
+
classification: DuelClassification;
|
|
48
|
+
hpBucket: DuelHpBucket;
|
|
49
|
+
fullHealth: boolean;
|
|
50
|
+
victimHealthBefore: number;
|
|
51
|
+
killerHealthBefore: number | null;
|
|
52
|
+
ttkMs: number | null;
|
|
53
|
+
thirdParty: boolean;
|
|
54
|
+
oneShotKill: boolean;
|
|
55
|
+
killerPosition: Vec3 | null;
|
|
56
|
+
victimPosition: Vec3;
|
|
57
|
+
facedAttacker: boolean | null;
|
|
58
|
+
evidenceTicks: DuelEvidenceTicks;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TtkDistribution {
|
|
62
|
+
count: number;
|
|
63
|
+
median: number | null;
|
|
64
|
+
p25: number | null;
|
|
65
|
+
p75: number | null;
|
|
66
|
+
histogram: Array<{ minMs: number; maxMs: number; count: number }>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DuelSignals {
|
|
70
|
+
version: "cs2-demo-analysis-kit/duel-signals-0.1";
|
|
71
|
+
tickrate: number;
|
|
72
|
+
records: DuelRecord[];
|
|
73
|
+
ttk: {
|
|
74
|
+
allFullHp: TtkDistribution;
|
|
75
|
+
byWeapon: Array<{ weapon: string; distribution: TtkDistribution }>;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DuelSignalsOptions {
|
|
80
|
+
visibility?: TriangleBvh | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface FlatShot {
|
|
84
|
+
roundNumber: number;
|
|
85
|
+
playerIndex: number;
|
|
86
|
+
tick: number;
|
|
87
|
+
weapon: string;
|
|
88
|
+
vx: number;
|
|
89
|
+
vy: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Engagement {
|
|
93
|
+
id: string;
|
|
94
|
+
roundNumber: number;
|
|
95
|
+
startTick: number;
|
|
96
|
+
endTick: number;
|
|
97
|
+
events: Array<{ tick: number; attackerIndex: number | null; victimIndex: number | null; kind: "shot" | "damage" | "kill" }>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function tickrateOf(pkg: DemoPackage): number {
|
|
101
|
+
return pkg.match.tickrate || pkg.manifest.tickrate || 64;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ticks(seconds: number, tickrate: number): number {
|
|
105
|
+
return Math.round(seconds * tickrate);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function msBetween(startTick: number, endTick: number, tickrate: number): number {
|
|
109
|
+
return Math.max(0, round((endTick - startTick) / tickrate * 1000, 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pct(sorted: number[], percentile: number): number | null {
|
|
113
|
+
if (sorted.length === 0) return null;
|
|
114
|
+
const index = (sorted.length - 1) * percentile;
|
|
115
|
+
const lower = Math.floor(index);
|
|
116
|
+
const upper = Math.ceil(index);
|
|
117
|
+
if (lower === upper) return sorted[lower]!;
|
|
118
|
+
return round(sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower), 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function distribution(values: number[]): TtkDistribution {
|
|
122
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
123
|
+
const buckets = [
|
|
124
|
+
{ minMs: 0, maxMs: 100, count: 0 },
|
|
125
|
+
{ minMs: 100, maxMs: 250, count: 0 },
|
|
126
|
+
{ minMs: 250, maxMs: 500, count: 0 },
|
|
127
|
+
{ minMs: 500, maxMs: 1000, count: 0 },
|
|
128
|
+
{ minMs: 1000, maxMs: 2000, count: 0 },
|
|
129
|
+
{ minMs: 2000, maxMs: Number.POSITIVE_INFINITY, count: 0 }
|
|
130
|
+
];
|
|
131
|
+
for (const value of sorted) {
|
|
132
|
+
const bucket = buckets.find((row) => value >= row.minMs && value < row.maxMs) ?? buckets[buckets.length - 1]!;
|
|
133
|
+
bucket.count += 1;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
count: sorted.length,
|
|
137
|
+
median: pct(sorted, 0.5),
|
|
138
|
+
p25: pct(sorted, 0.25),
|
|
139
|
+
p75: pct(sorted, 0.75),
|
|
140
|
+
histogram: buckets
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function flattenShots(shots: PackageShots | undefined): FlatShot[] {
|
|
145
|
+
if (!shots) return [];
|
|
146
|
+
const rows: FlatShot[] = [];
|
|
147
|
+
for (const track of shots.tracks) {
|
|
148
|
+
const trackTicks = decodeDelta(track.tick);
|
|
149
|
+
for (let i = 0; i < trackTicks.length; i++) {
|
|
150
|
+
const weaponIdx = track.weapon[i] ?? -1;
|
|
151
|
+
rows.push({
|
|
152
|
+
roundNumber: track.roundNumber,
|
|
153
|
+
playerIndex: track.playerIndex,
|
|
154
|
+
tick: trackTicks[i]!,
|
|
155
|
+
weapon: normalizeWeapon(weaponIdx >= 0 ? (shots.weaponDict[weaponIdx] ?? "") : ""),
|
|
156
|
+
vx: track.vx[i] ?? 0,
|
|
157
|
+
vy: track.vy[i] ?? 0
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return rows.sort((a, b) => a.roundNumber - b.roundNumber || a.tick - b.tick);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildEngagements(damages: PackageDamage[], pkg: DemoPackage, shots: FlatShot[], tickrate: number): Engagement[] {
|
|
165
|
+
const maxGap = ticks(ENGAGEMENT_GAP_SECONDS, tickrate);
|
|
166
|
+
const events = [
|
|
167
|
+
...shots.map((shot) => ({
|
|
168
|
+
roundNumber: shot.roundNumber,
|
|
169
|
+
tick: shot.tick,
|
|
170
|
+
attackerIndex: shot.playerIndex,
|
|
171
|
+
victimIndex: null,
|
|
172
|
+
kind: "shot" as const
|
|
173
|
+
})),
|
|
174
|
+
...damages.map((damage) => ({
|
|
175
|
+
roundNumber: damage.roundNumber,
|
|
176
|
+
tick: damage.tick,
|
|
177
|
+
attackerIndex: damage.attackerIndex,
|
|
178
|
+
victimIndex: damage.victimIndex,
|
|
179
|
+
kind: "damage" as const
|
|
180
|
+
})),
|
|
181
|
+
...pkg.kills.map((kill) => ({
|
|
182
|
+
roundNumber: kill.roundNumber,
|
|
183
|
+
tick: kill.tick,
|
|
184
|
+
attackerIndex: kill.killerIndex,
|
|
185
|
+
victimIndex: kill.victimIndex,
|
|
186
|
+
kind: "kill" as const
|
|
187
|
+
}))
|
|
188
|
+
].sort((a, b) => a.roundNumber - b.roundNumber || a.tick - b.tick);
|
|
189
|
+
|
|
190
|
+
const out: Engagement[] = [];
|
|
191
|
+
for (const event of events) {
|
|
192
|
+
const current = out[out.length - 1];
|
|
193
|
+
if (!current || current.roundNumber !== event.roundNumber || event.tick - current.endTick > maxGap) {
|
|
194
|
+
out.push({
|
|
195
|
+
id: `${event.roundNumber}:${event.tick}:${out.length}`,
|
|
196
|
+
roundNumber: event.roundNumber,
|
|
197
|
+
startTick: event.tick,
|
|
198
|
+
endTick: event.tick,
|
|
199
|
+
events: [event]
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
current.endTick = Math.max(current.endTick, event.tick);
|
|
203
|
+
current.events.push(event);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function engagementForKill(engagements: Engagement[], kill: PackageKill): Engagement {
|
|
210
|
+
return engagements.find((row) => row.roundNumber === kill.roundNumber && row.startTick <= kill.tick && row.endTick >= kill.tick) ?? {
|
|
211
|
+
id: `${kill.roundNumber}:${kill.tick}:fallback`,
|
|
212
|
+
roundNumber: kill.roundNumber,
|
|
213
|
+
startTick: kill.tick,
|
|
214
|
+
endTick: kill.tick,
|
|
215
|
+
events: []
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pairId(roundNumber: number, a: number, b: number): string {
|
|
220
|
+
const first = Math.min(a, b);
|
|
221
|
+
const second = Math.max(a, b);
|
|
222
|
+
return `${roundNumber}:${first}:${second}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function yawTo(target: Vec3, from: Vec3): number {
|
|
226
|
+
return Math.atan2(target.y - from.y, target.x - from.x) * 180 / Math.PI;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function angleDiff(a: number, b: number): number {
|
|
230
|
+
return Math.abs(((a - b + 540) % 360) - 180);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function replayTrackAtKill(pkg: DemoPackage, kill: PackageKill): { track: ReplayPlayerTrack; frameIndex: number } | null {
|
|
234
|
+
const replayRound = pkg.replay?.rounds.find((round) => round.roundNumber === kill.roundNumber);
|
|
235
|
+
if (!replayRound) return null;
|
|
236
|
+
const track = replayRound.players.find((player) => player.playerIndex === kill.victimIndex);
|
|
237
|
+
if (!track) return null;
|
|
238
|
+
const rawIndex = Math.round((kill.tick - replayRound.startTick) / replayRound.tickStep);
|
|
239
|
+
return { track, frameIndex: Math.max(0, Math.min(replayRound.frameCount - 1, rawIndex)) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function duelWindowForKill(pkg: DemoPackage, kill: PackageKill): DuelWindow | null {
|
|
243
|
+
return pkg.duels?.windows.find((window) =>
|
|
244
|
+
window.roundNumber === kill.roundNumber &&
|
|
245
|
+
window.anchors.some((anchor) => anchor.tick === kill.tick || Math.abs(anchor.tick - kill.tick) <= 2)
|
|
246
|
+
) ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function victimFrameInDuelWindow(pkg: DemoPackage, kill: PackageKill): { position: Vec3; yaw: number; moving: boolean } | null {
|
|
250
|
+
const window = duelWindowForKill(pkg, kill);
|
|
251
|
+
if (!window) return null;
|
|
252
|
+
const track = window.players.find((player) => player.playerIndex === kill.victimIndex);
|
|
253
|
+
if (!track) return null;
|
|
254
|
+
const index = Math.max(0, Math.min(window.frameCount - 1, Math.round((kill.tick - window.startTick) / window.tickStep)));
|
|
255
|
+
const x = decodeDelta(track.x);
|
|
256
|
+
const y = decodeDelta(track.y);
|
|
257
|
+
const z = decodeDelta(track.z);
|
|
258
|
+
const yaw = decodeDelta(track.yaw);
|
|
259
|
+
const prevIndex = Math.max(0, index - 1);
|
|
260
|
+
const dt = Math.max(1, index - prevIndex);
|
|
261
|
+
const distance = Math.hypot((x[index] ?? 0) - (x[prevIndex] ?? 0), (y[index] ?? 0) - (y[prevIndex] ?? 0));
|
|
262
|
+
const tickrate = pkg.duels?.meta.sampleRate ?? tickrateOf(pkg);
|
|
263
|
+
return {
|
|
264
|
+
position: {
|
|
265
|
+
x: (x[index] ?? kill.victimPosition.x) / (pkg.duels?.meta.coordScale ?? 1),
|
|
266
|
+
y: (y[index] ?? kill.victimPosition.y) / (pkg.duels?.meta.coordScale ?? 1),
|
|
267
|
+
z: (z[index] ?? kill.victimPosition.z) / (pkg.duels?.meta.coordScale ?? 1)
|
|
268
|
+
},
|
|
269
|
+
yaw: (yaw[index] ?? 0) / (pkg.duels?.meta.angleScale ?? 10),
|
|
270
|
+
moving: distance / dt * tickrate > RUNNING_SPEED_THRESHOLD
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function victimFacingState(pkg: DemoPackage, kill: PackageKill): { faced: boolean | null; moving: boolean } {
|
|
275
|
+
if (!kill.killerPosition) return { faced: null, moving: false };
|
|
276
|
+
const fromDuel = victimFrameInDuelWindow(pkg, kill);
|
|
277
|
+
if (fromDuel) {
|
|
278
|
+
return {
|
|
279
|
+
faced: angleDiff(fromDuel.yaw, yawTo(kill.killerPosition, fromDuel.position)) <= SUPPRESSED_ANGLE_DEGREES,
|
|
280
|
+
moving: fromDuel.moving
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const replay = replayTrackAtKill(pkg, kill);
|
|
284
|
+
if (!replay) return { faced: null, moving: false };
|
|
285
|
+
const angleScale = pkg.replay?.meta.angleScale ?? 10;
|
|
286
|
+
const coordScale = pkg.replay?.meta.coordScale ?? 1;
|
|
287
|
+
const xAbs = decodeDelta(replay.track.x);
|
|
288
|
+
const yAbs = decodeDelta(replay.track.y);
|
|
289
|
+
const zAbs = decodeDelta(replay.track.z);
|
|
290
|
+
const yawAbs = decodeDelta(replay.track.yaw);
|
|
291
|
+
const prevIndex = Math.max(0, replay.frameIndex - 1);
|
|
292
|
+
const position = {
|
|
293
|
+
x: (xAbs[replay.frameIndex] ?? kill.victimPosition.x) / coordScale,
|
|
294
|
+
y: (yAbs[replay.frameIndex] ?? kill.victimPosition.y) / coordScale,
|
|
295
|
+
z: (zAbs[replay.frameIndex] ?? kill.victimPosition.z) / coordScale
|
|
296
|
+
};
|
|
297
|
+
const distance = Math.hypot(
|
|
298
|
+
(xAbs[replay.frameIndex] ?? 0) - (xAbs[prevIndex] ?? 0),
|
|
299
|
+
(yAbs[replay.frameIndex] ?? 0) - (yAbs[prevIndex] ?? 0)
|
|
300
|
+
) / coordScale;
|
|
301
|
+
const moving = distance * (pkg.replay?.meta.sampleRate ?? 8) > RUNNING_SPEED_THRESHOLD;
|
|
302
|
+
const yaw = (yawAbs[replay.frameIndex] ?? 0) / angleScale;
|
|
303
|
+
return { faced: angleDiff(yaw, yawTo(kill.killerPosition, position)) <= SUPPRESSED_ANGLE_DEGREES, moving };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function victimResponseTick(shots: FlatShot[], kill: PackageKill, tickrate: number): number | null {
|
|
307
|
+
if (kill.killerIndex === null) return null;
|
|
308
|
+
const window = ticks(CONTESTED_WINDOW_SECONDS, tickrate);
|
|
309
|
+
return shots.find((shot) =>
|
|
310
|
+
shot.roundNumber === kill.roundNumber &&
|
|
311
|
+
shot.playerIndex === kill.victimIndex &&
|
|
312
|
+
Math.abs(shot.tick - kill.tick) <= window
|
|
313
|
+
)?.tick ?? null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function burstForKill(shots: FlatShot[], kill: PackageKill, tickrate: number): FlatShot[] {
|
|
317
|
+
if (kill.killerIndex === null) return [];
|
|
318
|
+
const maxGap = ticks(BURST_GAP_SECONDS, tickrate);
|
|
319
|
+
const targetWeapon = normalizeWeapon(killWeaponName(kill));
|
|
320
|
+
const prior = shots
|
|
321
|
+
.filter((shot) =>
|
|
322
|
+
shot.roundNumber === kill.roundNumber &&
|
|
323
|
+
shot.playerIndex === kill.killerIndex &&
|
|
324
|
+
shot.tick <= kill.tick &&
|
|
325
|
+
(!targetWeapon || shot.weapon === targetWeapon)
|
|
326
|
+
)
|
|
327
|
+
.sort((a, b) => a.tick - b.tick);
|
|
328
|
+
const bursts: FlatShot[][] = [];
|
|
329
|
+
for (const shot of prior) {
|
|
330
|
+
const current = bursts[bursts.length - 1];
|
|
331
|
+
if (!current || shot.tick - current[current.length - 1]!.tick > maxGap) bursts.push([shot]);
|
|
332
|
+
else current.push(shot);
|
|
333
|
+
}
|
|
334
|
+
return bursts[bursts.length - 1] ?? [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function victimHealthBefore(damages: PackageDamage[], kill: PackageKill): number {
|
|
338
|
+
const direct = damages
|
|
339
|
+
.filter((row) =>
|
|
340
|
+
row.roundNumber === kill.roundNumber &&
|
|
341
|
+
row.victimIndex === kill.victimIndex &&
|
|
342
|
+
row.tick <= kill.tick &&
|
|
343
|
+
(kill.killerIndex === null || row.attackerIndex === kill.killerIndex)
|
|
344
|
+
)
|
|
345
|
+
.sort((a, b) => a.tick - b.tick)[0];
|
|
346
|
+
return direct?.victimHealthBefore ?? 100;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function killerHealthBefore(damages: PackageDamage[], kill: PackageKill): number | null {
|
|
350
|
+
if (kill.killerIndex === null) return null;
|
|
351
|
+
const prior = damages
|
|
352
|
+
.filter((row) => row.roundNumber === kill.roundNumber && row.victimIndex === kill.killerIndex && row.tick <= kill.tick)
|
|
353
|
+
.sort((a, b) => b.tick - a.tick)[0];
|
|
354
|
+
return prior ? Math.max(0, prior.victimHealthBefore - prior.healthDamage) : 100;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function hasThirdPartyImpact(damages: PackageDamage[], pkg: DemoPackage, kill: PackageKill, engagement: Engagement): boolean {
|
|
358
|
+
if (kill.killerIndex === null) return true;
|
|
359
|
+
const pairedWindow = ticks(DUEL_PAIR_WINDOW_SECONDS, tickrateOf(pkg));
|
|
360
|
+
return damages.some((damage) =>
|
|
361
|
+
damage.roundNumber === kill.roundNumber &&
|
|
362
|
+
damage.victimIndex === kill.victimIndex &&
|
|
363
|
+
damage.attackerIndex !== null &&
|
|
364
|
+
damage.attackerIndex !== kill.killerIndex &&
|
|
365
|
+
Math.abs(damage.tick - kill.tick) <= pairedWindow &&
|
|
366
|
+
damage.tick >= engagement.startTick &&
|
|
367
|
+
damage.tick <= engagement.endTick
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isEnemyKill(resolver: PlayerResolver, kill: PackageKill): kill is PackageKill & { killerIndex: number } {
|
|
372
|
+
if (kill.killerIndex === null || kill.killerIndex === kill.victimIndex) return false;
|
|
373
|
+
const killer = resolver.byIndexOrNull(kill.killerIndex);
|
|
374
|
+
const victim = resolver.byIndexOrNull(kill.victimIndex);
|
|
375
|
+
return Boolean(killer && victim && killer.teamKey !== victim.teamKey);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isCleanDuelRecord(record: DuelRecord): boolean {
|
|
379
|
+
return !record.thirdParty && !record.throughSmoke && record.penetratedObjects <= 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 对枪三分类(视野时间线版):用「受害者 → 击杀者」可见性判定,比旧的 ±1.5s 任意开枪更准。
|
|
384
|
+
* - contested_duel:受害者在交火中对击杀者造成伤害,或在 [击杀者首发, 击杀] 间「看得到击杀者」时开过枪。
|
|
385
|
+
* - suppressed_kill:受害者死前曾有「看得到击杀者」的机会,但没有有效还手。
|
|
386
|
+
* - caught_off_guard:受害者死前从未获得有效可见机会(无有效视野 / 被预瞄)。
|
|
387
|
+
* 可见性用 duels 满 tick 窗口的视野锥 + hp + flash + 烟雾 + 静态 LOS(调用方传入 .tri 时)。
|
|
388
|
+
* 无窗口时回退启发式。
|
|
389
|
+
*/
|
|
390
|
+
function classifyDuel(
|
|
391
|
+
ctx: VisibilityContext,
|
|
392
|
+
damages: PackageDamage[],
|
|
393
|
+
kill: PackageKill & { killerIndex: number },
|
|
394
|
+
window: DuelWindow | null,
|
|
395
|
+
shots: FlatShot[],
|
|
396
|
+
engagement: Engagement,
|
|
397
|
+
killerFirstShotTick: number | null,
|
|
398
|
+
fallback: DuelClassification
|
|
399
|
+
): DuelClassification {
|
|
400
|
+
if (!window) return fallback;
|
|
401
|
+
const view = decodeDuelWindow(ctx.pkg, window);
|
|
402
|
+
const victimDamagedKiller = damages.some((damage) =>
|
|
403
|
+
damage.roundNumber === kill.roundNumber &&
|
|
404
|
+
damage.attackerIndex === kill.victimIndex &&
|
|
405
|
+
damage.victimIndex === kill.killerIndex &&
|
|
406
|
+
damage.tick >= engagement.startTick &&
|
|
407
|
+
damage.tick <= kill.tick
|
|
408
|
+
);
|
|
409
|
+
if (victimDamagedKiller) return "contested_duel";
|
|
410
|
+
const contestStart = killerFirstShotTick ?? engagement.startTick;
|
|
411
|
+
const victimShots = shots.filter((shot) =>
|
|
412
|
+
shot.roundNumber === kill.roundNumber && shot.playerIndex === kill.victimIndex && shot.tick >= contestStart && shot.tick <= kill.tick
|
|
413
|
+
);
|
|
414
|
+
for (const shot of victimShots) {
|
|
415
|
+
if (isVisibleAt(ctx, view, kill.victimIndex, kill.killerIndex, frameIndexForTick(view, shot.tick))) return "contested_duel";
|
|
416
|
+
}
|
|
417
|
+
const killFrame = frameIndexForTick(view, kill.tick);
|
|
418
|
+
for (let frame = 0; frame <= killFrame; frame++) {
|
|
419
|
+
if (isVisibleAt(ctx, view, kill.victimIndex, kill.killerIndex, frame)) return "suppressed_kill";
|
|
420
|
+
}
|
|
421
|
+
return "caught_off_guard";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** buildDuelsSignals 默认路径按 pkg 实例记忆化;传入 visibility 时结果依赖 BVH,跳过全局缓存。 */
|
|
425
|
+
const duelSignalsCache = new WeakMap<DemoPackage, DuelSignals>();
|
|
426
|
+
|
|
427
|
+
export function buildDuelsSignals(input: DemoPackage, options: DuelSignalsOptions = {}): DuelSignals {
|
|
428
|
+
const cacheable = options.visibility == null;
|
|
429
|
+
const cached = cacheable ? duelSignalsCache.get(input) : undefined;
|
|
430
|
+
if (cached) return cached;
|
|
431
|
+
const pkg = input;
|
|
432
|
+
const resolver = createResolverFromPackage(pkg);
|
|
433
|
+
const tickrate = tickrateOf(pkg);
|
|
434
|
+
const ctx: VisibilityContext = { pkg, visibility: options.visibility };
|
|
435
|
+
const shots = flattenShots(pkg.shots);
|
|
436
|
+
const damages = activeDamages(pkg);
|
|
437
|
+
const engagements = buildEngagements(damages, pkg, shots, tickrate);
|
|
438
|
+
const records = pkg.kills
|
|
439
|
+
.filter((kill) => isEnemyKill(resolver, kill))
|
|
440
|
+
.map((kill, index): DuelRecord => {
|
|
441
|
+
const killer = resolver.byIndex(kill.killerIndex!);
|
|
442
|
+
const victim = resolver.byIndex(kill.victimIndex);
|
|
443
|
+
const engagement = engagementForKill(engagements, kill);
|
|
444
|
+
const responseTick = victimResponseTick(shots, kill, tickrate);
|
|
445
|
+
const facing = victimFacingState(pkg, kill);
|
|
446
|
+
const burst = burstForKill(shots, kill, tickrate);
|
|
447
|
+
const window = duelWindowForKill(pkg, kill);
|
|
448
|
+
// 有 duels 满 tick 窗口时用「受害者 → 击杀者」可见性时间线判定;否则回退到还手/朝向启发式。
|
|
449
|
+
const fallbackClass: DuelClassification = responseTick != null
|
|
450
|
+
? "contested_duel"
|
|
451
|
+
: facing.faced === true && !facing.moving
|
|
452
|
+
? "suppressed_kill"
|
|
453
|
+
: "caught_off_guard";
|
|
454
|
+
const classification = classifyDuel(ctx, damages, kill, window, shots, engagement, burst[0]?.tick ?? null, fallbackClass);
|
|
455
|
+
const hp = victimHealthBefore(damages, kill);
|
|
456
|
+
const hpBucket: DuelHpBucket = hp >= FULL_HEALTH_HP ? "full_hp" : "low_hp";
|
|
457
|
+
const thirdParty = hasThirdPartyImpact(damages, pkg, kill, engagement);
|
|
458
|
+
const ttkMs = hpBucket === "full_hp" && !thirdParty && burst.length > 0
|
|
459
|
+
? msBetween(burst[0]!.tick, kill.tick, tickrate)
|
|
460
|
+
: null;
|
|
461
|
+
return {
|
|
462
|
+
id: `${kill.roundNumber}-${kill.tick}-${kill.killerIndex}-${kill.victimIndex}-${index}`,
|
|
463
|
+
roundNumber: kill.roundNumber,
|
|
464
|
+
tick: kill.tick,
|
|
465
|
+
engagementId: engagement.id,
|
|
466
|
+
duelPairId: pairId(kill.roundNumber, kill.killerIndex!, kill.victimIndex),
|
|
467
|
+
killerSteamId64: killer.steamId64,
|
|
468
|
+
victimSteamId64: victim.steamId64,
|
|
469
|
+
killerName: killer.name,
|
|
470
|
+
victimName: victim.name,
|
|
471
|
+
killerTeamKey: killer.teamKey,
|
|
472
|
+
victimTeamKey: victim.teamKey,
|
|
473
|
+
killerIndex: kill.killerIndex!,
|
|
474
|
+
victimIndex: kill.victimIndex,
|
|
475
|
+
weapon: killWeaponName(kill),
|
|
476
|
+
headshot: kill.headshot,
|
|
477
|
+
throughSmoke: kill.throughSmoke,
|
|
478
|
+
penetratedObjects: kill.penetratedObjects ?? 0,
|
|
479
|
+
classification,
|
|
480
|
+
hpBucket,
|
|
481
|
+
fullHealth: hpBucket === "full_hp",
|
|
482
|
+
victimHealthBefore: hp,
|
|
483
|
+
killerHealthBefore: killerHealthBefore(damages, kill),
|
|
484
|
+
ttkMs,
|
|
485
|
+
thirdParty,
|
|
486
|
+
oneShotKill: burst.length === 1,
|
|
487
|
+
killerPosition: kill.killerPosition,
|
|
488
|
+
victimPosition: kill.victimPosition,
|
|
489
|
+
facedAttacker: facing.faced,
|
|
490
|
+
evidenceTicks: {
|
|
491
|
+
engagementStartTick: engagement.startTick,
|
|
492
|
+
engagementEndTick: engagement.endTick,
|
|
493
|
+
killerFirstShotTick: burst[0]?.tick ?? null,
|
|
494
|
+
victimResponseTick: responseTick,
|
|
495
|
+
killTick: kill.tick,
|
|
496
|
+
windowStartTick: window?.startTick,
|
|
497
|
+
windowEndTick: window ? window.startTick + window.tickStep * Math.max(0, window.frameCount - 1) : undefined
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
})
|
|
501
|
+
.sort((a, b) => a.roundNumber - b.roundNumber || a.tick - b.tick);
|
|
502
|
+
|
|
503
|
+
const fullHpTtk = records
|
|
504
|
+
.filter((record) => record.hpBucket === "full_hp" && isCleanDuelRecord(record) && record.ttkMs != null)
|
|
505
|
+
.map((record) => record.ttkMs!);
|
|
506
|
+
const weaponKeys = [...new Set(records.map((record) => normalizeWeapon(record.weapon)))].sort();
|
|
507
|
+
const signals: DuelSignals = {
|
|
508
|
+
version: "cs2-demo-analysis-kit/duel-signals-0.1",
|
|
509
|
+
tickrate,
|
|
510
|
+
records,
|
|
511
|
+
ttk: {
|
|
512
|
+
allFullHp: distribution(fullHpTtk),
|
|
513
|
+
byWeapon: weaponKeys.map((weapon) => ({
|
|
514
|
+
weapon,
|
|
515
|
+
distribution: distribution(records
|
|
516
|
+
.filter((record) => normalizeWeapon(record.weapon) === weapon && record.hpBucket === "full_hp" && isCleanDuelRecord(record) && record.ttkMs != null)
|
|
517
|
+
.map((record) => record.ttkMs!))
|
|
518
|
+
}))
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
if (cacheable) duelSignalsCache.set(input, signals);
|
|
522
|
+
return signals;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function deriveDuels(pkg: DemoPackage, options: DuelSignalsOptions = {}): DuelRecord[] {
|
|
526
|
+
return buildDuelsSignals(pkg, options).records;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function deriveOpeningDuels(pkg: DemoPackage, options: DuelSignalsOptions = {}): DuelRecord[] {
|
|
530
|
+
const seen = new Set<number>();
|
|
531
|
+
const rows: DuelRecord[] = [];
|
|
532
|
+
for (const duel of deriveDuels(pkg, options)) {
|
|
533
|
+
if (seen.has(duel.roundNumber)) continue;
|
|
534
|
+
seen.add(duel.roundNumber);
|
|
535
|
+
rows.push(duel);
|
|
536
|
+
}
|
|
537
|
+
return rows;
|
|
538
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { analyzeDemoPackage, loadDemoPackageFromZip } from "./index";
|
|
5
|
+
|
|
6
|
+
const cologneSmokeFixture = fileURLToPath(
|
|
7
|
+
new URL("../../../fixtures/input/cologne-major-2026-stage3-smoke-de_nuke.zip", import.meta.url)
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe("fixture invariants", () => {
|
|
11
|
+
it("loads the Cologne Major cs2df 3.1.0 smoke fixture end-to-end", async () => {
|
|
12
|
+
const pkg = await loadDemoPackageFromZip(await readFile(cologneSmokeFixture));
|
|
13
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
14
|
+
|
|
15
|
+
expect(pkg.manifest.schemaVersion).toBe("cs2-demo-format/3.0");
|
|
16
|
+
expect(pkg.manifest.exporter).toMatchObject({ name: "cs2df", version: "3.1.0" });
|
|
17
|
+
expect(pkg.manifest.demo?.sourceFileName).toBe("aurora-vs-9z-m1-nuke.dem");
|
|
18
|
+
expect(pkg.match.mapName).toBe("de_nuke");
|
|
19
|
+
expect(pkg.match.teamA.name).toBe("9z");
|
|
20
|
+
expect(pkg.match.teamB.name).toBe("Aurora Gaming");
|
|
21
|
+
expect([pkg.match.teamA.score, pkg.match.teamB.score]).toEqual([1, 13]);
|
|
22
|
+
expect(pkg.players).toHaveLength(10);
|
|
23
|
+
expect(pkg.rounds).toHaveLength(14);
|
|
24
|
+
expect(pkg.kills).toHaveLength(94);
|
|
25
|
+
expect(pkg.damages).toHaveLength(352);
|
|
26
|
+
expect(pkg.bombs).toHaveLength(41);
|
|
27
|
+
expect(pkg.grenades).toHaveLength(212);
|
|
28
|
+
expect(pkg.shots).toMatchObject({ meta: expect.any(Object), tracks: expect.any(Array) });
|
|
29
|
+
expect(pkg.replay).toMatchObject({ meta: expect.any(Object), rounds: expect.any(Array) });
|
|
30
|
+
expect(pkg.duels).toMatchObject({ meta: expect.any(Object), windows: expect.any(Array) });
|
|
31
|
+
|
|
32
|
+
expect(bundle.qa.ok).toBe(true);
|
|
33
|
+
expect(bundle.qa.summary.issueCount).toBe(0);
|
|
34
|
+
expect(bundle.scoreboard).toHaveLength(10);
|
|
35
|
+
expect(bundle.economy).toHaveLength(14);
|
|
36
|
+
expect(bundle.timeline.length).toBeGreaterThan(pkg.kills.length);
|
|
37
|
+
expect(bundle.heatmap.length).toBeGreaterThan(pkg.damages.length);
|
|
38
|
+
expect(bundle.provenance.sourceDemoHash).toBe(pkg.manifest.demo?.hash);
|
|
39
|
+
});
|
|
40
|
+
});
|