@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/LICENSE CHANGED
@@ -1,3 +1,10 @@
1
+ Licensing note / 适用范围
2
+ -------------------------
3
+ The MIT License below applies to this repository EXCEPT apps/dak-studio/,
4
+ which is licensed under the GNU AGPL-3.0-only (see apps/dak-studio/LICENSE).
5
+ Ecosystem packages (@cs2dak/*, python exporter) are MIT; the DAK Studio
6
+ product is AGPL. Third-party attributions: THIRD-PARTY-NOTICES.md.
7
+
1
8
  MIT License
2
9
 
3
10
  Copyright (c) 2026 Starfie1d
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@cs2dak/core",
3
- "version": "0.2.1",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@rivalhub/rival-rating": "^0.1.0",
10
+ "@rivalhub/rival-rating": "^0.3.0",
11
11
  "jszip": "^3.10.1",
12
- "@cs2dak/contract": "0.2.1"
12
+ "@cs2dak/contract": "1.1.0",
13
+ "@cs2dak/maps": "1.0.0"
13
14
  },
14
15
  "files": [
15
16
  "src"
@@ -0,0 +1,199 @@
1
+ import type { DemoPackage, DuelWindow, PackageGrenade } from "@cs2dak/contract";
2
+ import { decodeDelta } from "@cs2dak/contract";
3
+ import { staticLineOfSight, type TriangleBvh, type Vec3 } from "@cs2dak/maps";
4
+
5
+ /**
6
+ * duels.json 满 tick 窗口的可见性原语。
7
+ *
8
+ * 围绕 kill/damage 锚点的连续逐 tick 流(x/y/z/yaw/pitch/hp/flash)让我们能在
9
+ * 交火窗口内严格计算「敌人何时进入有效视野」「开枪前是否完成停稳」「预瞄误差」,
10
+ * 而不必依赖全局 8Hz replay 或静态 LOS 退化口径。该模块只解码 + 几何判定,不做指标聚合。
11
+ */
12
+
13
+ /** 眼睛相对脚底坐标的 z 偏移(游戏单位)。LOS 与视野锥都从眼睛发射。 */
14
+ export const EYE_HEIGHT = 64;
15
+ /** 目标取胸口高度,略低于眼睛,减少擦边遮挡误判。 */
16
+ export const TARGET_HEIGHT = 56;
17
+ /** 有效视野锥半角(度)。CS2 FOV 约 90°,取 40° 半角作为「能交火/已捕获目标」的判定锥。 */
18
+ export const VIEW_CONE_HALF_DEGREES = 40;
19
+ /** CS2 烟雾半径约 144 游戏单位,用于射线-球遮挡判定。 */
20
+ export const SMOKE_RADIUS = 144;
21
+
22
+ const SMOKE_TYPES = new Set(["smoke", "smokegrenade"]);
23
+
24
+ export interface DecodedTrack {
25
+ playerIndex: number;
26
+ x: number[];
27
+ y: number[];
28
+ z: number[];
29
+ yaw: number[];
30
+ pitch: number[];
31
+ hp: number[];
32
+ flash: number[];
33
+ }
34
+
35
+ export interface DuelWindowView {
36
+ window: DuelWindow;
37
+ startTick: number;
38
+ tickStep: number;
39
+ frameCount: number;
40
+ tracks: Map<number, DecodedTrack>;
41
+ }
42
+
43
+ const viewCache = new WeakMap<DuelWindow, DuelWindowView>();
44
+
45
+ function decodeTrack(track: DuelWindow["players"][number], coordScale: number, angleScale: number): DecodedTrack {
46
+ const scale = (arr: number[], by: number) => arr.map((value) => value / by);
47
+ return {
48
+ playerIndex: track.playerIndex,
49
+ x: scale(decodeDelta(track.x), coordScale),
50
+ y: scale(decodeDelta(track.y), coordScale),
51
+ z: scale(decodeDelta(track.z), coordScale),
52
+ yaw: scale(decodeDelta(track.yaw), angleScale),
53
+ pitch: scale(decodeDelta(track.pitch), angleScale),
54
+ hp: [...track.hp],
55
+ flash: [...track.flash]
56
+ };
57
+ }
58
+
59
+ /** 解码一个 duel 窗口的全部玩家轨迹(按 pkg 内 window 实例记忆化)。 */
60
+ export function decodeDuelWindow(pkg: DemoPackage, window: DuelWindow): DuelWindowView {
61
+ const cached = viewCache.get(window);
62
+ if (cached) return cached;
63
+ const coordScale = pkg.duels?.meta.coordScale ?? 1;
64
+ const angleScale = pkg.duels?.meta.angleScale ?? 10;
65
+ const tracks = new Map<number, DecodedTrack>();
66
+ for (const track of window.players) tracks.set(track.playerIndex, decodeTrack(track, coordScale, angleScale));
67
+ const view: DuelWindowView = {
68
+ window,
69
+ startTick: window.startTick,
70
+ tickStep: window.tickStep,
71
+ frameCount: window.frameCount,
72
+ tracks
73
+ };
74
+ viewCache.set(window, view);
75
+ return view;
76
+ }
77
+
78
+ /** 找到覆盖 (round, tick) 的 duel 窗口;窗口同回合内已合并,故至多一个。 */
79
+ export function findWindowCovering(pkg: DemoPackage, roundNumber: number, tick: number): DuelWindow | null {
80
+ if (!pkg.duels) return null;
81
+ return pkg.duels.windows.find((window) =>
82
+ window.roundNumber === roundNumber &&
83
+ window.startTick <= tick &&
84
+ window.startTick + window.tickStep * Math.max(0, window.frameCount - 1) >= tick
85
+ ) ?? null;
86
+ }
87
+
88
+ /** tick → 最近帧索引(夹在 [0, frameCount-1])。 */
89
+ export function frameIndexForTick(view: DuelWindowView, tick: number): number {
90
+ const raw = Math.round((tick - view.startTick) / view.tickStep);
91
+ return Math.max(0, Math.min(view.frameCount - 1, raw));
92
+ }
93
+
94
+ export function tickAtFrame(view: DuelWindowView, frame: number): number {
95
+ return view.startTick + frame * view.tickStep;
96
+ }
97
+
98
+ function forwardVector(yawDeg: number, pitchDeg: number): Vec3 {
99
+ const yaw = (yawDeg * Math.PI) / 180;
100
+ const pitch = (pitchDeg * Math.PI) / 180;
101
+ const cosPitch = Math.cos(pitch);
102
+ return { x: cosPitch * Math.cos(yaw), y: cosPitch * Math.sin(yaw), z: -Math.sin(pitch) };
103
+ }
104
+
105
+ function normalize(v: Vec3): Vec3 | null {
106
+ const len = Math.hypot(v.x, v.y, v.z);
107
+ if (len < 1e-6) return null;
108
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
109
+ }
110
+
111
+ function dot(a: Vec3, b: Vec3): number {
112
+ return a.x * b.x + a.y * b.y + a.z * b.z;
113
+ }
114
+
115
+ /** 准星视线方向与目标方向的三维夹角(度)。yaw+pitch 都参与。null = 重合/无法定义。 */
116
+ export function aimErrorDegrees(yawDeg: number, pitchDeg: number, eye: Vec3, target: Vec3): number | null {
117
+ const aim = forwardVector(yawDeg, pitchDeg);
118
+ const toTarget = normalize({ x: target.x - eye.x, y: target.y - eye.y, z: target.z - eye.z });
119
+ if (!toTarget) return null;
120
+ const cos = Math.max(-1, Math.min(1, dot(aim, toTarget)));
121
+ return (Math.acos(cos) * 180) / Math.PI;
122
+ }
123
+
124
+ /** 目标是否落在视野锥内(半角 VIEW_CONE_HALF_DEGREES)。 */
125
+ export function insideViewCone(yawDeg: number, pitchDeg: number, eye: Vec3, target: Vec3): boolean {
126
+ const err = aimErrorDegrees(yawDeg, pitchDeg, eye, target);
127
+ return err != null && err <= VIEW_CONE_HALF_DEGREES;
128
+ }
129
+
130
+ function pointToSegmentDistance(point: Vec3, a: Vec3, b: Vec3): number {
131
+ const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
132
+ const ap = { x: point.x - a.x, y: point.y - a.y, z: point.z - a.z };
133
+ const denom = dot(ab, ab);
134
+ const t = denom < 1e-6 ? 0 : Math.max(0, Math.min(1, dot(ap, ab) / denom));
135
+ const closest = { x: a.x + ab.x * t, y: a.y + ab.y * t, z: a.z + ab.z * t };
136
+ return Math.hypot(point.x - closest.x, point.y - closest.y, point.z - closest.z);
137
+ }
138
+
139
+ /** 在 tick 时刻,是否有生效中的烟雾遮挡 from→to 视线。 */
140
+ export function smokeBlocksRay(grenades: PackageGrenade[] | undefined, roundNumber: number, tick: number, from: Vec3, to: Vec3): boolean {
141
+ if (!grenades || grenades.length === 0) return false;
142
+ for (const grenade of grenades) {
143
+ if (grenade.roundNumber !== roundNumber) continue;
144
+ if (!SMOKE_TYPES.has(grenade.grenade)) continue;
145
+ const active = grenade.effectTick <= tick && (grenade.destroyTick == null || tick <= grenade.destroyTick);
146
+ if (!active) continue;
147
+ if (pointToSegmentDistance(grenade.effectPosition, from, to) <= SMOKE_RADIUS) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ export interface VisibilityContext {
153
+ pkg: DemoPackage;
154
+ visibility?: TriangleBvh | null;
155
+ }
156
+
157
+ /**
158
+ * attacker 在 frame 是否对 victim 有「有效可见」:双方存活、attacker 未被闪、
159
+ * victim 落在 attacker 视野锥内、静态 LOS 通透(有 .tri 时)、且无烟雾遮挡。
160
+ * 缺 .tri 时跳过 LOS(只保留 hp/flash/锥/烟),由调用方决定是否信任。
161
+ */
162
+ export function isVisibleAt(
163
+ ctx: VisibilityContext,
164
+ view: DuelWindowView,
165
+ attackerIndex: number,
166
+ victimIndex: number,
167
+ frame: number
168
+ ): boolean {
169
+ const attacker = view.tracks.get(attackerIndex);
170
+ const victim = view.tracks.get(victimIndex);
171
+ if (!attacker || !victim) return false;
172
+ if ((attacker.hp[frame] ?? 0) <= 0 || (victim.hp[frame] ?? 0) <= 0) return false;
173
+ if ((attacker.flash[frame] ?? 0) > 0) return false;
174
+ const eye: Vec3 = { x: attacker.x[frame] ?? 0, y: attacker.y[frame] ?? 0, z: (attacker.z[frame] ?? 0) + EYE_HEIGHT };
175
+ const target: Vec3 = { x: victim.x[frame] ?? 0, y: victim.y[frame] ?? 0, z: (victim.z[frame] ?? 0) + TARGET_HEIGHT };
176
+ if (!insideViewCone(attacker.yaw[frame] ?? 0, attacker.pitch[frame] ?? 0, eye, target)) return false;
177
+ if (ctx.visibility && !staticLineOfSight(ctx.visibility, eye, target)) return false;
178
+ const tick = tickAtFrame(view, frame);
179
+ if (smokeBlocksRay(ctx.pkg.grenades, view.window.roundNumber, tick, eye, target)) return false;
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * 从 fromFrame 向前回溯,返回「当前连续可见段」的起始帧。
185
+ * - fromFrame 本身不可见 → null(prefire / 未捕获目标,调用方判 prefire)。
186
+ * - 可见段一直延伸到窗口起点(frame 0)→ 返回 0,但调用方应视为左截断(窗口外早已可见)。
187
+ */
188
+ export function visibleOnsetFrame(
189
+ ctx: VisibilityContext,
190
+ view: DuelWindowView,
191
+ attackerIndex: number,
192
+ victimIndex: number,
193
+ fromFrame: number
194
+ ): number | null {
195
+ if (!isVisibleAt(ctx, view, attackerIndex, victimIndex, fromFrame)) return null;
196
+ let onset = fromFrame;
197
+ while (onset > 0 && isVisibleAt(ctx, view, attackerIndex, victimIndex, onset - 1)) onset -= 1;
198
+ return onset;
199
+ }
@@ -0,0 +1,370 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Duels, PackageDamage, PackageKill, PackageShots, Replay } from "@cs2dak/contract";
3
+ import { buildTriangleBvh } from "@cs2dak/maps";
4
+ import { buildDuelsSignals, deriveDuels, deriveOpeningDuels } from "./duels.js";
5
+
6
+ const A = "76561198000000001";
7
+ const B = "76561198000000002";
8
+ const C = "76561198000000003";
9
+
10
+ // Player indices matching pkg().players order
11
+ const AI = 0;
12
+ const BI = 1;
13
+ const CI = 2;
14
+
15
+ function damage(row: { attackerIndex: number | null; victimIndex: number; tick: number } & Partial<PackageDamage>): PackageDamage {
16
+ return {
17
+ roundNumber: 1,
18
+ weapon: "ak47",
19
+ hitgroup: "chest",
20
+ healthDamage: 40,
21
+ healthDamageRaw: 40,
22
+ armorDamage: 0,
23
+ victimHealthBefore: 100,
24
+ attackerPosition: { x: 0, y: 0, z: 0 },
25
+ victimPosition: { x: 100, y: 0, z: 0 },
26
+ ...row
27
+ } as PackageDamage;
28
+ }
29
+
30
+ function kill(row: { killerIndex: number | null; victimIndex: number; tick: number } & Partial<PackageKill>): PackageKill {
31
+ return {
32
+ roundNumber: 1,
33
+ assisterIndex: null,
34
+ flashAssisterIndex: null,
35
+ weapon: "ak47",
36
+ killerActiveWeapon: "ak47",
37
+ victimActiveWeapon: "ak47",
38
+ headshot: false,
39
+ flashAssist: false,
40
+ tradeKill: false,
41
+ tradeDeath: false,
42
+ throughSmoke: false,
43
+ noScope: false,
44
+ penetratedObjects: 0,
45
+ killerPosition: { x: 0, y: 0, z: 0 },
46
+ victimPosition: { x: 100, y: 0, z: 0 },
47
+ ...row
48
+ } as PackageKill;
49
+ }
50
+
51
+ /** Build columnar PackageShots from a flat shot list. Ticks are delta-encoded per track. */
52
+ function buildShots(
53
+ shots: Array<{ tick: number; playerIndex: number; roundNumber?: number }>
54
+ ): PackageShots {
55
+ const groups = new Map<string, Array<{ tick: number; playerIndex: number; roundNumber: number }>>();
56
+ for (const s of shots) {
57
+ const rn = s.roundNumber ?? 1;
58
+ const key = `${rn}:${s.playerIndex}`;
59
+ const arr = groups.get(key) ?? [];
60
+ arr.push({ tick: s.tick, playerIndex: s.playerIndex, roundNumber: rn });
61
+ groups.set(key, arr);
62
+ }
63
+ const tracks: PackageShots["tracks"] = [];
64
+ for (const items of groups.values()) {
65
+ const deltas: number[] = [];
66
+ let prev = 0;
67
+ for (const { tick } of items) { deltas.push(tick - prev); prev = tick; }
68
+ const n = items.length;
69
+ tracks.push({
70
+ roundNumber: items[0]!.roundNumber,
71
+ playerIndex: items[0]!.playerIndex,
72
+ tick: deltas,
73
+ weapon: Array<number>(n).fill(0),
74
+ vx: Array<number>(n).fill(0),
75
+ vy: Array<number>(n).fill(0),
76
+ vz: Array<number>(n).fill(0),
77
+ yaw: Array<number>(n).fill(0),
78
+ pitch: Array<number>(n).fill(0),
79
+ x: Array<number>(n).fill(0),
80
+ y: Array<number>(n).fill(0),
81
+ z: Array<number>(n).fill(0),
82
+ });
83
+ }
84
+ return { meta: { coordScale: 1, angleScale: 10 }, weaponDict: ["ak47"], tracks };
85
+ }
86
+
87
+ function encodeDelta(values: number[]): number[] {
88
+ const out: number[] = [];
89
+ let prev = 0;
90
+ for (const value of values) { out.push(value - prev); prev = value; }
91
+ return out;
92
+ }
93
+
94
+ function duelTrack(playerIndex: number, abs: { x: number[]; y: number[]; z: number[]; yaw: number[]; pitch: number[]; hp: number[]; flash: number[] }) {
95
+ return {
96
+ playerIndex,
97
+ x: encodeDelta(abs.x),
98
+ y: encodeDelta(abs.y),
99
+ z: encodeDelta(abs.z),
100
+ yaw: encodeDelta(abs.yaw.map((deg) => deg * 10)),
101
+ pitch: encodeDelta(abs.pitch.map((deg) => deg * 10)),
102
+ hp: abs.hp,
103
+ flash: abs.flash
104
+ };
105
+ }
106
+
107
+ function pkg(overrides: Partial<DemoPackage>): DemoPackage {
108
+ return {
109
+ manifest: {
110
+ schemaVersion: "cs2-demo-format/3.0",
111
+ exporter: { name: "test", version: "0" },
112
+ parser: { name: "test", version: "0" },
113
+ demo: { hash: null, sourceFileName: null },
114
+ mapName: "de_mirage",
115
+ tickrate: 64,
116
+ exportedAt: "2026-01-01T00:00:00Z",
117
+ files: {
118
+ match: "match.json",
119
+ players: "players.json",
120
+ rounds: "rounds.json",
121
+ playerStats: "player-stats.json",
122
+ playerEconomies: "player-economies.json",
123
+ kills: "kills.json",
124
+ damages: "damages.json",
125
+ blinds: "blinds.json",
126
+ bombs: "bombs.json",
127
+ grenades: "grenades.json",
128
+ clutches: "clutches.json"
129
+ }
130
+ },
131
+ match: {
132
+ mapName: "de_mirage",
133
+ tickrate: 64,
134
+ durationSeconds: 120,
135
+ serverName: null,
136
+ source: "test",
137
+ teamA: { teamKey: "teamA", name: "A", score: 1 },
138
+ teamB: { teamKey: "teamB", name: "B", score: 0 }
139
+ },
140
+ players: [
141
+ { steamId64: A, name: "A", teamKey: "teamA" },
142
+ { steamId64: B, name: "B", teamKey: "teamB" },
143
+ { steamId64: C, name: "C", teamKey: "teamB" }
144
+ ],
145
+ rounds: [{
146
+ roundNumber: 1,
147
+ startTick: 1,
148
+ freezeEndTick: 64,
149
+ endTick: 4096,
150
+ teamASide: "t",
151
+ teamBSide: "ct",
152
+ teamAScoreBefore: 0,
153
+ teamBScoreBefore: 0,
154
+ teamAEconomy: "full",
155
+ teamBEconomy: "full",
156
+ winnerTeamKey: "teamA",
157
+ winnerSide: "t"
158
+ }],
159
+ playerEconomies: [],
160
+ playerStats: [],
161
+ kills: [],
162
+ damages: [],
163
+ blinds: [],
164
+ bombs: [],
165
+ grenades: [],
166
+ clutches: [],
167
+ ...overrides
168
+ } as DemoPackage;
169
+ }
170
+
171
+ describe("deriveDuels", () => {
172
+ it("classifies mutual damage inside the window as contested and computes burst-anchored ttk", () => {
173
+ const demo = pkg({
174
+ damages: [
175
+ damage({ tick: 100, attackerIndex: AI, victimIndex: BI }),
176
+ damage({ tick: 120, attackerIndex: BI, victimIndex: AI }),
177
+ damage({ tick: 150, attackerIndex: AI, victimIndex: BI, victimHealthBefore: 40 })
178
+ ],
179
+ kills: [kill({ tick: 150, killerIndex: AI, victimIndex: BI })],
180
+ // A fires at 96,108; B fires at 120 — B response tick 120 is in window [150-192,150]
181
+ shots: buildShots([
182
+ { tick: 96, playerIndex: AI },
183
+ { tick: 108, playerIndex: AI },
184
+ { tick: 120, playerIndex: BI }
185
+ ])
186
+ });
187
+
188
+ const rows = deriveDuels(demo);
189
+ expect(rows).toHaveLength(1);
190
+ expect(rows[0]).toMatchObject({
191
+ killerSteamId64: A,
192
+ victimSteamId64: B,
193
+ classification: "contested_duel",
194
+ fullHealth: true,
195
+ ttkMs: 843.8 // (150 - 96) / 64 * 1000 = 843.75 → 843.8
196
+ });
197
+ });
198
+
199
+ it("separates suppressed kill from caught off guard using replay yaw when victim does not return fire", () => {
200
+ const replay: Replay = {
201
+ meta: { sampleRate: 8, tickrate: 64, coordScale: 1, angleScale: 10 },
202
+ weaponDict: [],
203
+ placeDict: [],
204
+ rounds: [{
205
+ roundNumber: 1,
206
+ startTick: 64,
207
+ tickStep: 8,
208
+ frameCount: 1,
209
+ projectiles: [],
210
+ players: [
211
+ {
212
+ // B faces 180° (toward killer A at x=0, from x=100) → outaimed
213
+ playerIndex: BI,
214
+ x: [100], y: [0], z: [0],
215
+ yaw: [1800], // 180° × angleScale 10
216
+ pitch: [0], hp: [0], armor: [0], money: [0], equipValue: [0],
217
+ place: [0], flash: [0], flags: [0], weapon: [-1], grenades: [[]]
218
+ },
219
+ {
220
+ // C faces 90° (perpendicular to killer) → caught_off_guard
221
+ playerIndex: CI,
222
+ x: [100], y: [0], z: [0],
223
+ yaw: [900], // 90° × angleScale 10
224
+ pitch: [0], hp: [0], armor: [0], money: [0], equipValue: [0],
225
+ place: [0], flash: [0], flags: [0], weapon: [-1], grenades: [[]]
226
+ }
227
+ ]
228
+ }]
229
+ };
230
+
231
+ const demo = pkg({
232
+ replay,
233
+ damages: [
234
+ damage({ tick: 64, attackerIndex: AI, victimIndex: BI }),
235
+ damage({ tick: 64, attackerIndex: AI, victimIndex: CI })
236
+ ],
237
+ kills: [
238
+ kill({ tick: 64, killerIndex: AI, victimIndex: BI }),
239
+ kill({ tick: 64, killerIndex: AI, victimIndex: CI })
240
+ ],
241
+ shots: buildShots([{ tick: 64, playerIndex: AI }])
242
+ });
243
+
244
+ const rows = deriveDuels(demo);
245
+ expect(rows.find((row) => row.victimSteamId64 === B)?.classification).toBe("suppressed_kill");
246
+ expect(rows.find((row) => row.victimSteamId64 === C)?.classification).toBe("caught_off_guard");
247
+ });
248
+
249
+ it("keeps one-tap ttk near zero when the kill lands on the first burst shot", () => {
250
+ const demo = pkg({
251
+ damages: [damage({ tick: 100, attackerIndex: AI, victimIndex: BI, hitgroup: "head", healthDamage: 100 })],
252
+ kills: [kill({ tick: 100, killerIndex: AI, victimIndex: BI, headshot: true })],
253
+ shots: buildShots([{ tick: 100, playerIndex: AI }])
254
+ });
255
+
256
+ const row = deriveDuels(demo)[0]!;
257
+ expect(row.oneShotKill).toBe(true);
258
+ expect(row.ttkMs).toBe(0);
259
+ });
260
+
261
+ it("uses static LOS for full-tick classification when visibility is provided", () => {
262
+ const duels: Duels = {
263
+ meta: { tickrate: 64, sampleRate: 64, coordScale: 1, angleScale: 10, windowBeforeMs: 1000, windowAfterMs: 1000 },
264
+ windows: [{
265
+ roundNumber: 1,
266
+ startTick: 100,
267
+ tickStep: 1,
268
+ frameCount: 1,
269
+ anchors: [{ kind: "kill", tick: 100, attackerIndex: AI, victimIndex: BI }],
270
+ players: [
271
+ duelTrack(AI, { x: [-5], y: [0], z: [0], yaw: [0], pitch: [0], hp: [100], flash: [0] }),
272
+ duelTrack(BI, { x: [5], y: [0], z: [0], yaw: [180], pitch: [0], hp: [100], flash: [0] })
273
+ ]
274
+ }]
275
+ };
276
+ const demo = pkg({
277
+ duels,
278
+ damages: [damage({ tick: 100, attackerIndex: AI, victimIndex: BI })],
279
+ kills: [kill({ tick: 100, killerIndex: AI, victimIndex: BI })],
280
+ shots: buildShots([{ tick: 100, playerIndex: AI }])
281
+ });
282
+ const wall = buildTriangleBvh([{
283
+ a: { x: 0, y: -10, z: 0 },
284
+ b: { x: 0, y: 10, z: 0 },
285
+ c: { x: 0, y: 0, z: 100 }
286
+ }]);
287
+
288
+ expect(deriveDuels(demo)[0]!.classification).toBe("suppressed_kill");
289
+ expect(deriveDuels(demo, { visibility: wall })[0]!.classification).toBe("caught_off_guard");
290
+ });
291
+
292
+ it("treats 80 HP as full hp but excludes third-party damage from ttk distribution", () => {
293
+ const demo = pkg({
294
+ damages: [
295
+ damage({ tick: 90, attackerIndex: CI, victimIndex: BI, victimHealthBefore: 100, healthDamage: 20 }),
296
+ damage({ tick: 100, attackerIndex: AI, victimIndex: BI, victimHealthBefore: 80, healthDamage: 80 })
297
+ ],
298
+ kills: [kill({ tick: 100, killerIndex: AI, victimIndex: BI })],
299
+ shots: buildShots([{ tick: 92, playerIndex: AI }])
300
+ });
301
+
302
+ const signals = buildDuelsSignals(demo);
303
+ expect(signals.records[0]).toMatchObject({
304
+ hpBucket: "full_hp",
305
+ fullHealth: true,
306
+ victimHealthBefore: 80,
307
+ thirdParty: true,
308
+ ttkMs: null
309
+ });
310
+ expect(signals.ttk.allFullHp.count).toBe(0);
311
+ });
312
+
313
+ it("excludes through-smoke and wallbang kills from ttk distribution", () => {
314
+ const demo = pkg({
315
+ damages: [
316
+ damage({ tick: 100, attackerIndex: AI, victimIndex: BI }),
317
+ damage({ tick: 200, attackerIndex: AI, victimIndex: CI })
318
+ ],
319
+ kills: [
320
+ kill({ tick: 100, killerIndex: AI, victimIndex: BI, throughSmoke: true }),
321
+ kill({ tick: 200, killerIndex: AI, victimIndex: CI, penetratedObjects: 1 })
322
+ ],
323
+ shots: buildShots([
324
+ { tick: 100, playerIndex: AI },
325
+ { tick: 200, playerIndex: AI }
326
+ ])
327
+ });
328
+
329
+ const signals = buildDuelsSignals(demo);
330
+ expect(signals.ttk.allFullHp.count).toBe(0);
331
+ expect(signals.ttk.byWeapon.find((row) => row.weapon === "ak47")?.distribution.count).toBe(0);
332
+ });
333
+
334
+ it("marks low-hp cleanup pressure without polluting full-hp ttk", () => {
335
+ const demo = pkg({
336
+ damages: [damage({ tick: 100, attackerIndex: AI, victimIndex: BI, victimHealthBefore: 79 })],
337
+ kills: [kill({ tick: 100, killerIndex: AI, victimIndex: BI })],
338
+ shots: buildShots([{ tick: 90, playerIndex: AI }])
339
+ });
340
+
341
+ const signals = buildDuelsSignals(demo);
342
+ expect(signals.records[0]).toMatchObject({
343
+ hpBucket: "low_hp",
344
+ fullHealth: false,
345
+ ttkMs: null
346
+ });
347
+ expect(signals.ttk.allFullHp.count).toBe(0);
348
+ });
349
+ });
350
+
351
+ describe("deriveOpeningDuels", () => {
352
+ it("returns only the first duel per round", () => {
353
+ const demo = pkg({
354
+ damages: [
355
+ damage({ tick: 100, attackerIndex: AI, victimIndex: BI }),
356
+ damage({ tick: 200, attackerIndex: AI, victimIndex: CI })
357
+ ],
358
+ kills: [
359
+ kill({ tick: 100, killerIndex: AI, victimIndex: BI }),
360
+ kill({ tick: 200, killerIndex: AI, victimIndex: CI })
361
+ ],
362
+ shots: buildShots([
363
+ { tick: 100, playerIndex: AI },
364
+ { tick: 200, playerIndex: AI }
365
+ ])
366
+ });
367
+
368
+ expect(deriveOpeningDuels(demo).map((row) => row.victimSteamId64)).toEqual([B]);
369
+ });
370
+ });