@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.
Files changed (62) 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 +46 -53
  8. package/src/index.ts +25 -8
  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 +27 -1
  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/signals.ts +149 -112
  35. package/src/spatial/annotate.test.ts +133 -0
  36. package/src/spatial/annotate.ts +163 -0
  37. package/src/spatial/index.ts +16 -0
  38. package/src/spatial/mapcontrol.test.ts +131 -0
  39. package/src/spatial/mapcontrol.ts +277 -0
  40. package/src/spatial/phase.test.ts +171 -0
  41. package/src/spatial/phase.ts +179 -0
  42. package/src/spatial/trade-closure.test.ts +38 -0
  43. package/src/spatial/types.ts +56 -0
  44. package/src/spatial/utility-geometry.test.ts +88 -0
  45. package/src/spatial/utility-geometry.ts +167 -0
  46. package/src/spatial/utility.integration.test.ts +38 -0
  47. package/src/spatial/utility.test.ts +120 -0
  48. package/src/spatial/utility.ts +399 -0
  49. package/src/tactics/formations.ts +151 -0
  50. package/src/tactics/index.ts +16 -0
  51. package/src/tactics/replay-round-context.ts +96 -0
  52. package/src/tactics/round-facts.ts +406 -0
  53. package/src/tactics/segments.ts +68 -0
  54. package/src/tactics/tactics.test.ts +112 -0
  55. package/src/tactics/types.ts +73 -0
  56. package/src/timeline.ts +73 -52
  57. package/src/utility-facts.test.ts +55 -0
  58. package/src/utility-facts.ts +137 -0
  59. package/src/utils.ts +27 -25
  60. package/src/weapon-highlights.ts +4 -4
  61. package/src/economy.test.ts +0 -37
  62. package/src/economy.ts +0 -57
@@ -0,0 +1,375 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Duels, PackageDamage, PackageKill, PackageShots } from "@cs2dak/contract";
3
+ import { buildMechanicsSignals, counterStrafeThresholdForWeapon, derivePlayerMechanics } from "./mechanics.js";
4
+
5
+ const A = "76561198000000001";
6
+ const AI = 0;
7
+
8
+ /** Build columnar PackageShots from a flat shot list. */
9
+ function buildShots(
10
+ shots: Array<{ tick: number; playerIndex: number; vx?: number; vy?: number; weaponIndex?: number }>
11
+ ): PackageShots {
12
+ const groups = new Map<string, Array<{ tick: number; playerIndex: number; vx: number; vy: number; weaponIndex: number }>>();
13
+ for (const s of shots) {
14
+ const key = `1:${s.playerIndex}`;
15
+ const arr = groups.get(key) ?? [];
16
+ arr.push({ tick: s.tick, playerIndex: s.playerIndex, vx: s.vx ?? 0, vy: s.vy ?? 0, weaponIndex: s.weaponIndex ?? 0 });
17
+ groups.set(key, arr);
18
+ }
19
+ const tracks: PackageShots["tracks"] = [];
20
+ for (const items of groups.values()) {
21
+ const deltas: number[] = [];
22
+ let prev = 0;
23
+ for (const { tick } of items) { deltas.push(tick - prev); prev = tick; }
24
+ const n = items.length;
25
+ tracks.push({
26
+ roundNumber: 1,
27
+ playerIndex: items[0]!.playerIndex,
28
+ tick: deltas,
29
+ weapon: items.map((s) => s.weaponIndex),
30
+ vx: items.map((s) => s.vx),
31
+ vy: items.map((s) => s.vy),
32
+ vz: Array<number>(n).fill(0),
33
+ yaw: Array<number>(n).fill(0),
34
+ pitch: Array<number>(n).fill(0),
35
+ x: Array<number>(n).fill(0),
36
+ y: Array<number>(n).fill(0),
37
+ z: Array<number>(n).fill(0),
38
+ });
39
+ }
40
+ return { meta: { coordScale: 1, angleScale: 10 }, weaponDict: ["ak47", "flashbang", "knife"], tracks };
41
+ }
42
+
43
+ function damage(tick: number): PackageDamage {
44
+ return {
45
+ roundNumber: 1,
46
+ tick,
47
+ attackerIndex: AI,
48
+ victimIndex: 1,
49
+ weapon: "ak47",
50
+ hitgroup: "head",
51
+ healthDamage: 100,
52
+ healthDamageRaw: 100,
53
+ armorDamage: 0,
54
+ victimHealthBefore: 100,
55
+ victimArmorAfter: 100,
56
+ attackerPosition: { x: 0, y: 0, z: 0 },
57
+ victimPosition: { x: 100, y: 0, z: 0 }
58
+ };
59
+ }
60
+
61
+ function kill(tick: number, weapon = "ak47"): PackageKill {
62
+ return {
63
+ roundNumber: 1,
64
+ tick,
65
+ killerIndex: AI,
66
+ victimIndex: 1,
67
+ assisterIndex: null,
68
+ flashAssisterIndex: null,
69
+ weapon,
70
+ killerActiveWeapon: weapon,
71
+ victimActiveWeapon: null,
72
+ headshot: false,
73
+ flashAssist: false,
74
+ tradeKill: false,
75
+ tradeDeath: false,
76
+ throughSmoke: false,
77
+ noScope: false,
78
+ penetratedObjects: 0,
79
+ killerPosition: { x: 0, y: 0, z: 0 },
80
+ victimPosition: { x: 100, y: 0, z: 0 }
81
+ };
82
+ }
83
+
84
+ function pkg(overrides: Partial<DemoPackage>): DemoPackage {
85
+ return {
86
+ manifest: {
87
+ schemaVersion: "cs2-demo-format/3.0",
88
+ exporter: { name: "test", version: "0" },
89
+ parser: { name: "test", version: "0" },
90
+ demo: { hash: null, sourceFileName: null },
91
+ mapName: "de_mirage",
92
+ tickrate: 64,
93
+ exportedAt: "2026-01-01T00:00:00Z",
94
+ files: {
95
+ match: "match.json",
96
+ players: "players.json",
97
+ rounds: "rounds.json",
98
+ playerStats: "player-stats.json",
99
+ playerEconomies: "player-economies.json",
100
+ kills: "kills.json",
101
+ damages: "damages.json",
102
+ blinds: "blinds.json",
103
+ bombs: "bombs.json",
104
+ grenades: "grenades.json",
105
+ clutches: "clutches.json"
106
+ }
107
+ },
108
+ match: {
109
+ mapName: "de_mirage",
110
+ tickrate: 64,
111
+ durationSeconds: 120,
112
+ serverName: null,
113
+ source: "test",
114
+ teamA: { teamKey: "teamA", name: "A", score: 1 },
115
+ teamB: { teamKey: "teamB", name: "B", score: 0 }
116
+ },
117
+ players: [{ steamId64: A, name: "A", teamKey: "teamA" }, { steamId64: "76561198000000002", name: "B", teamKey: "teamB" }],
118
+ rounds: [],
119
+ playerEconomies: [],
120
+ playerStats: [],
121
+ kills: [],
122
+ damages: [],
123
+ blinds: [],
124
+ bombs: [],
125
+ grenades: [],
126
+ clutches: [],
127
+ ...overrides
128
+ } as DemoPackage;
129
+ }
130
+
131
+ describe("derivePlayerMechanics", () => {
132
+ it("derives counter-strafe thresholds from weapon movement speed", () => {
133
+ expect(counterStrafeThresholdForWeapon("ak47")).toMatchObject({ weapon: "ak47", weaponMaxSpeed: 215, maxSpeed: 73.1, source: "weapon" });
134
+ expect(counterStrafeThresholdForWeapon("m4a1_silencer")).toMatchObject({ weapon: "m4a1_silencer", weaponMaxSpeed: 225, maxSpeed: 76.5, source: "weapon" });
135
+ expect(counterStrafeThresholdForWeapon("awp")).toMatchObject({ weapon: "awp", weaponMaxSpeed: 100, maxSpeed: 34, source: "weapon" });
136
+ expect(counterStrafeThresholdForWeapon("ump45")).toMatchObject({ weapon: "ump45", weaponMaxSpeed: 230, maxSpeed: 78.2, source: "weapon" });
137
+ expect(counterStrafeThresholdForWeapon("mp9")).toMatchObject({ weapon: "mp9", weaponMaxSpeed: 240, maxSpeed: 81.6, source: "weapon" });
138
+ });
139
+
140
+ it("splits bursts and computes first-shot, spray, rhythm, and counter-strafe metrics", () => {
141
+ const demo = pkg({
142
+ shots: buildShots([
143
+ { tick: 100, playerIndex: AI, weaponIndex: 0 }, // ak47
144
+ { tick: 110, playerIndex: AI, weaponIndex: 0, vx: 20 },
145
+ { tick: 120, playerIndex: AI, weaponIndex: 0, vx: 20 },
146
+ { tick: 130, playerIndex: AI, weaponIndex: 0, vx: 20 },
147
+ { tick: 140, playerIndex: AI, weaponIndex: 0, vx: 120 },
148
+ { tick: 300, playerIndex: AI, weaponIndex: 0 },
149
+ { tick: 310, playerIndex: AI, weaponIndex: 1 }, // flashbang
150
+ { tick: 320, playerIndex: AI, weaponIndex: 2 }, // knife
151
+ ]),
152
+ damages: [damage(100), damage(140)],
153
+ kills: [kill(140)]
154
+ });
155
+
156
+ const row = derivePlayerMechanics(demo)[0]!;
157
+ expect(row).toMatchObject({ steamId64: A, weapon: "ak47", killCount: 1, burstCount: 2, shotCount: 6 });
158
+ // 只有交火 burst(造成伤害的 burst1)计入首发;尾随的 300 burst 既无伤害也无 duels 窗口,被排除。
159
+ expect(row.firstShotHit).toEqual({ value: 100, successes: 1, attempts: 1 });
160
+ // ak 自动武器,burst1 长度 5 → 第 4 发起 [130,140],仅 140 命中。
161
+ expect(row.sprayHit).toEqual({ value: 50, successes: 1, attempts: 2 });
162
+ // 无 duels 窗口 → 拿不到开枪前连续轨迹,无法判定移动 → 不计入。
163
+ expect(row.counterStrafe.attempts).toBe(0);
164
+ expect(row.counterStrafe.value).toBeNull();
165
+ expect(row.oneTap).toEqual({ value: 0, successes: 0, attempts: 1 });
166
+ expect(row.ttk).toEqual({ value: 625, sampleSize: 1 });
167
+ expect(row.burstLengthBuckets).toEqual({ single: 1, short: 0, medium: 1, long: 0 });
168
+ expect(row.medianShotIntervalMs).toBeGreaterThan(0);
169
+ expect(row.firingPatternRatio).toEqual({ tap: 50, burst: 50, spray: 0 });
170
+ expect(buildMechanicsSignals(demo)).toMatchObject({
171
+ version: "cs2-demo-analysis-kit/mechanics-signals-0.2",
172
+ burstGapSeconds: 0.25
173
+ });
174
+ expect(derivePlayerMechanics(demo).map((item) => item.weapon)).toEqual(["ak47"]);
175
+ });
176
+
177
+ it("hides counter-strafe when exporter velocity is unavailable", () => {
178
+ const demo = pkg({
179
+ shots: buildShots([
180
+ { tick: 100, playerIndex: AI },
181
+ { tick: 300, playerIndex: AI },
182
+ ]),
183
+ damages: [damage(100)]
184
+ });
185
+
186
+ expect(derivePlayerMechanics(demo)[0]!.counterStrafe.value).toBeNull();
187
+ });
188
+
189
+ it("sorts weapon rows by kill count before shot volume", () => {
190
+ const demo = pkg({
191
+ shots: buildShots([
192
+ { tick: 100, playerIndex: AI, weaponIndex: 0 },
193
+ { tick: 110, playerIndex: AI, weaponIndex: 0 },
194
+ { tick: 120, playerIndex: AI, weaponIndex: 0 },
195
+ { tick: 300, playerIndex: AI, weaponIndex: 0 }, // deagle(0) — same weaponIndex
196
+ ]),
197
+ kills: [kill(300, "deagle")]
198
+ });
199
+
200
+ const rows = derivePlayerMechanics(demo);
201
+ expect(rows.length).toBeGreaterThanOrEqual(1);
202
+ expect(rows[0]!.weapon).toBe("ak47");
203
+ });
204
+
205
+ it("returns an empty list when shots are unavailable", () => {
206
+ const noShots: DemoPackage = { ...pkg({}), shots: undefined as unknown as PackageShots };
207
+ expect(derivePlayerMechanics(noShots)).toEqual([]);
208
+ });
209
+ });
210
+
211
+ // ── duels.json 满 tick 窗口路径:急停 / 反应 / 预瞄 ──
212
+
213
+ function encodeDelta(values: number[]): number[] {
214
+ const out: number[] = [];
215
+ let prev = 0;
216
+ for (const value of values) { out.push(value - prev); prev = value; }
217
+ return out;
218
+ }
219
+
220
+ function duelTrack(playerIndex: number, abs: { x: number[]; y: number[]; z: number[]; yaw: number[]; pitch: number[]; hp: number[]; flash: number[] }) {
221
+ return {
222
+ playerIndex,
223
+ x: encodeDelta(abs.x),
224
+ y: encodeDelta(abs.y),
225
+ z: encodeDelta(abs.z),
226
+ yaw: encodeDelta(abs.yaw.map((deg) => deg * 10)),
227
+ pitch: encodeDelta(abs.pitch.map((deg) => deg * 10)),
228
+ hp: abs.hp,
229
+ flash: abs.flash
230
+ };
231
+ }
232
+
233
+ describe("derivePlayerMechanics with duels window", () => {
234
+ it("derives counter-strafe, reaction onset, and preaim from the full-tick track", () => {
235
+ const N = 200; // 帧 i 对应 tick 100+i,覆盖 100..299
236
+ const killerX: number[] = [];
237
+ const killerFlash: number[] = [];
238
+ for (let i = 0; i < N; i++) {
239
+ const tick = 100 + i;
240
+ // 击杀者在 tick 183..193 内移动(用于急停尝试),之后停稳
241
+ killerX.push(tick <= 182 ? 0 : tick <= 193 ? (tick - 182) * 10 : 110);
242
+ // 被闪到 tick 185,186 起恢复视野 → 反应 onset = tick 186
243
+ killerFlash.push(tick <= 185 ? 10 : 0);
244
+ }
245
+ const constant = (value: number) => Array<number>(N).fill(value);
246
+ const duels: Duels = {
247
+ meta: { tickrate: 64, sampleRate: 64, coordScale: 1, angleScale: 10, windowBeforeMs: 2000, windowAfterMs: 1000 },
248
+ windows: [{
249
+ roundNumber: 1,
250
+ startTick: 100,
251
+ tickStep: 1,
252
+ frameCount: N,
253
+ anchors: [{ kind: "kill", tick: 200, attackerIndex: AI, victimIndex: 1 }],
254
+ players: [
255
+ duelTrack(AI, { x: killerX, y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: constant(100), flash: killerFlash }),
256
+ duelTrack(1, { x: constant(2000), y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: constant(100), flash: constant(0) })
257
+ ]
258
+ }]
259
+ };
260
+
261
+ const demo = pkg({
262
+ shots: buildShots([
263
+ { tick: 196, playerIndex: AI, weaponIndex: 0 }, // 首发已停稳(vx/vy = 0)
264
+ { tick: 198, playerIndex: AI, weaponIndex: 0 },
265
+ { tick: 200, playerIndex: AI, weaponIndex: 0, vx: 30 } // 后续仍有移动 → 全局 velocity 非全零
266
+ ]),
267
+ damages: [damage(196), damage(200)],
268
+ kills: [kill(200)],
269
+ duels
270
+ });
271
+
272
+ const row = derivePlayerMechanics(demo)[0]!;
273
+ // 急停:开枪前在移动(>100 u/s),开枪时停稳(≤ AK 判定用最大移速 34%)→ 成功一次
274
+ expect(row.counterStrafe).toEqual({ value: 100, successes: 1, attempts: 1 });
275
+ // 反应:onset = tick 186(被闪恢复),首发 196 → (196-186)/64*1000 = 156.3ms
276
+ expect(row.reaction.sampleSize).toBe(1);
277
+ expect(row.reaction.value).toBe(156.3);
278
+ // 预瞄:onset 前 3 帧准星对准敌人 → 中位误差极小,命中 ≤5°
279
+ expect(row.preaim.sampleSize).toBe(1);
280
+ expect(row.preaim.withinFiveCount).toBe(1);
281
+ expect(row.preaim.medianDegrees).not.toBeNull();
282
+ expect(row.preaim.medianDegrees!).toBeLessThan(5);
283
+ // 首发命中(196 命中)+ 击杀耗时(196→200)
284
+ expect(row.firstShotHit).toEqual({ value: 100, successes: 1, attempts: 1 });
285
+ expect(row.ttk).toEqual({ value: 62.5, sampleSize: 1 });
286
+ });
287
+
288
+ it("uses the previous alive frame as reaction anchor for lethal first shots", () => {
289
+ const N = 120; // 帧 i 对应 tick 100+i
290
+ const constant = (value: number) => Array<number>(N).fill(value);
291
+ const killerFlash = Array.from({ length: N }, (_, i) => (100 + i <= 170 ? 10 : 0));
292
+ const victimHp = Array<number>(N).fill(100);
293
+ victimHp[80] = 0; // shotTick == killTick == 180 时,窗口帧已记录受害者死亡
294
+ const duels: Duels = {
295
+ meta: { tickrate: 64, sampleRate: 64, coordScale: 1, angleScale: 10, windowBeforeMs: 2000, windowAfterMs: 1000 },
296
+ windows: [{
297
+ roundNumber: 1,
298
+ startTick: 100,
299
+ tickStep: 1,
300
+ frameCount: N,
301
+ anchors: [{ kind: "kill", tick: 180, attackerIndex: AI, victimIndex: 1 }],
302
+ players: [
303
+ duelTrack(AI, { x: constant(0), y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: constant(100), flash: killerFlash }),
304
+ duelTrack(1, { x: constant(2000), y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: victimHp, flash: constant(0) })
305
+ ]
306
+ }]
307
+ };
308
+
309
+ const demo = pkg({
310
+ shots: buildShots([{ tick: 180, playerIndex: AI, weaponIndex: 0 }]),
311
+ damages: [damage(180)],
312
+ kills: [kill(180)],
313
+ duels
314
+ });
315
+
316
+ const row = derivePlayerMechanics(demo)[0]!;
317
+ // onset = tick 171(闪光结束后的第一帧),首发/击杀 tick 180。
318
+ expect(row.reaction).toEqual({ value: 140.6, sampleSize: 1 });
319
+ expect(row.preaim.sampleSize).toBe(1);
320
+ expect(row.oneTap).toEqual({ value: 100, successes: 1, attempts: 1 });
321
+ });
322
+
323
+ it("uses the full-tick track speed for counter-strafe success when shot velocity is noisy", () => {
324
+ const N = 140;
325
+ const killerX: number[] = [];
326
+ for (let i = 0; i < N; i++) {
327
+ const tick = 100 + i;
328
+ killerX.push(tick <= 182 ? 0 : tick <= 193 ? (tick - 182) * 10 : 110);
329
+ }
330
+ const constant = (value: number) => Array<number>(N).fill(value);
331
+ const duels: Duels = {
332
+ meta: { tickrate: 64, sampleRate: 64, coordScale: 1, angleScale: 10, windowBeforeMs: 2000, windowAfterMs: 1000 },
333
+ windows: [{
334
+ roundNumber: 1,
335
+ startTick: 100,
336
+ tickStep: 1,
337
+ frameCount: N,
338
+ anchors: [{ kind: "kill", tick: 200, attackerIndex: AI, victimIndex: 1 }],
339
+ players: [
340
+ duelTrack(AI, { x: killerX, y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: constant(100), flash: constant(0) }),
341
+ duelTrack(1, { x: constant(2000), y: constant(0), z: constant(0), yaw: constant(0), pitch: constant(0), hp: constant(100), flash: constant(0) })
342
+ ]
343
+ }]
344
+ };
345
+ const demo = pkg({
346
+ shots: buildShots([
347
+ { tick: 196, playerIndex: AI, weaponIndex: 0, vx: 450 },
348
+ { tick: 200, playerIndex: AI, weaponIndex: 0, vx: 30 }
349
+ ]),
350
+ damages: [damage(196), damage(200)],
351
+ kills: [kill(200)],
352
+ duels
353
+ });
354
+
355
+ expect(derivePlayerMechanics(demo)[0]!.counterStrafe).toEqual({ value: 100, successes: 1, attempts: 1 });
356
+ });
357
+
358
+ it("excludes through-smoke and wallbang kills from clean kill-time and one-tap samples", () => {
359
+ const demo = pkg({
360
+ shots: buildShots([
361
+ { tick: 100, playerIndex: AI, weaponIndex: 0 },
362
+ { tick: 220, playerIndex: AI, weaponIndex: 0 }
363
+ ]),
364
+ damages: [damage(100), damage(220)],
365
+ kills: [
366
+ { ...kill(100), throughSmoke: true },
367
+ { ...kill(220), penetratedObjects: 1 }
368
+ ]
369
+ });
370
+ const row = derivePlayerMechanics(demo)[0]!;
371
+
372
+ expect(row.ttk).toEqual({ value: null, sampleSize: 0 });
373
+ expect(row.oneTap).toEqual({ value: null, successes: 0, attempts: 0 });
374
+ });
375
+ });