@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,399 @@
1
+ /**
2
+ * Official UtilitySpatial actual-effect 派生(严格重建 SP3 v2)。设计见 rr-model.md §3.4、§14、§18。
3
+ *
4
+ * 解锁前提:legacy zone 多边形(4/7 图)+ nav 拓扑(7 图)+ tri-BVH 静态视线(按需,分析侧)。
5
+ * 注意:zone 只服务本 shadow utility 几何路径,不是当前控图/战术语义真相源。
6
+ *
7
+ * 手雷归属(doc §18):effectPosition → zoneAt(非「最近 player」proxy)。
8
+ *
9
+ * 5 项 actual-effect:
10
+ * - actualSmokeIsolationSeconds :nav 绕路代价(屏蔽烟覆盖区后 enemy→site 多走多远)× 时长。
11
+ * - actualSmokeSightlineDenialSeconds :tri-BVH 判定烟切断 enemy→site 的**静态可见**枪线(需 visibility)。
12
+ * - actualSmokeProtectedCrossings :队友穿越时被烟挡住敌方原本可见的枪线(需 visibility)。
13
+ * - actualIncendiaryPathDelaySeconds :火落通行要道且敌人在场 × 燃烧时长 × 角色权重。
14
+ * - actualIncendiaryDisplacementEvents:敌人火前在区、火后离开或掉血(1Hz 采样放宽窗口)。
15
+ *
16
+ * 无 visibility(production 不加载 207MB tri)→ 两项 LOS 指标发 null。
17
+ *
18
+ * ⚠️ 冻结状态(2026-06-07,SP3 v2 收尾):本实现保持 **shadow**(不进 RR 评分),不再继续打磨。
19
+ * 实测净 ΔRR 极小(mean 0.011 / p90 0.025 / 无人 >0.05),远小于 Trade 闭环(mean 0.033),
20
+ * 正式进 RR 价值有限。两处「零值」为模型/启发式固有缺陷,**非 bug,不在此修**:
21
+ * - isolation(①):nav 绕路假设「烟挡移动」,但烟只挡视线、人能走过去。开阔图(mirage)平行路多、
22
+ * 144 半径封不住唯一路径 → detour 恒 0;仅 choke 图(ancient/inferno)出值。是 nav 绕路模型的固有弱点。
23
+ * - sightlineDenial(②):objective 取 site 质心→最近 nav 区的 3D 点,dust2 该点常被墙挡 →
24
+ * staticLOS 本就 false → 无枪线可封 → 偏低。是 objective 选点启发式脆弱,非视线判定错。
25
+ * vision-based 重构(isolation 改判「烟切断敌人对目标区的视线安全」,本质并入 sightlineDenial)
26
+ * 推迟到权重 ramp 阶段三——职业样本到位、有校准数据后,再连同 sightlineDenial 一起重设计。
27
+ * 见 rr-model.md §3.4 / §3.6。
28
+ */
29
+ import type { DemoPackage } from "@cs2dak/contract";
30
+ import type { TriangleBvh, Vec3, ZoneRole } from "@cs2dak/maps";
31
+ import { staticLineOfSight, zoneAt } from "@cs2dak/maps";
32
+ import { type SpatialAssets, type AnnotatedSample, annotatePositions } from "./annotate.js";
33
+ import { inferRoundPhases, phaseAtTick } from "./phase.js";
34
+ import { isOfficialScoringPhase, type RoundPhaseModel } from "./types.js";
35
+ import {
36
+ areasWithinRadius,
37
+ buildNavIndex,
38
+ nearestAreaId,
39
+ polygonCentroid,
40
+ segmentSphereIntersects,
41
+ smokeDetourCost,
42
+ type NavIndex,
43
+ SMOKE_RADIUS,
44
+ } from "./utility-geometry.js";
45
+
46
+ export type GrenadeType = "smoke" | "molotov" | "incendiary" | "flash" | "he" | "decoy" | "unknown";
47
+
48
+ export interface UtilityWindow {
49
+ grenadeId: string;
50
+ roundNumber: number;
51
+ type: GrenadeType;
52
+ throwerSteamId64: string | null;
53
+ throwerTeamKey: string | null;
54
+ effectTick: number;
55
+ destroyTick: number;
56
+ effectPosition: Vec3;
57
+ zoneId: string | null;
58
+ zoneRole: ZoneRole | null;
59
+ zoneBombsite: "a" | "b" | null;
60
+ }
61
+
62
+ export interface OfficialUtilitySpatial {
63
+ actualIncendiaryDisplacementEvents: number;
64
+ actualSmokeIsolationSeconds: number;
65
+ actualIncendiaryPathDelaySeconds: number;
66
+ /** 需 tri-BVH(visibility);缺失为 null(不可观测,非 0)。 */
67
+ actualSmokeProtectedCrossings: number | null;
68
+ actualSmokeSightlineDenialSeconds: number | null;
69
+ }
70
+
71
+ const DISPLACE_BEFORE_SECONDS = 2; // 火前回看
72
+ const DISPLACE_AFTER_SECONDS = 4; // 火后回看(1Hz 采样放宽)
73
+ const FIRE_TYPES: ReadonlySet<GrenadeType> = new Set(["molotov", "incendiary"]);
74
+ const FIRE_BURN_SECONDS = 7; // 火焰缺 destroyTick 时兜底
75
+ const SMOKE_DURATION_SECONDS = 18;
76
+ const DETOUR_FULL_UNITS = 700; // nav 绕路 ≥ 此值 → 隔离系数 1.0
77
+ const SIGHTLINE_PER_ENEMY = 0.5; // 每条被封枪线的重要性
78
+
79
+ function effectiveDurationSeconds(w: UtilityWindow, tickrate: number): number {
80
+ if (w.destroyTick > w.effectTick) return (w.destroyTick - w.effectTick) / tickrate;
81
+ if (FIRE_TYPES.has(w.type)) return FIRE_BURN_SECONDS;
82
+ if (w.type === "smoke") return SMOKE_DURATION_SECONDS;
83
+ return 0;
84
+ }
85
+
86
+ function roleWeight(role: ZoneRole | null): number {
87
+ switch (role) {
88
+ case "site": return 1.0;
89
+ case "lane": case "connector": case "mid": return 0.8;
90
+ case "approach": case "backsite": return 0.6;
91
+ default: return 0;
92
+ }
93
+ }
94
+
95
+ export function buildUtilityWindows(pkg: DemoPackage, assets: SpatialAssets): UtilityWindow[] {
96
+ const out: UtilityWindow[] = [];
97
+ for (const g of pkg.grenades ?? []) {
98
+ const effectPosition = toVec3((g as { effectPosition?: Partial<Vec3> }).effectPosition);
99
+ const zone = assets.zones ? zoneAt(assets.zones, effectPosition.x, effectPosition.y, effectPosition.z) : null;
100
+ out.push({
101
+ grenadeId: String((g as { grenadeId?: unknown }).grenadeId ?? ""),
102
+ roundNumber: g.roundNumber,
103
+ type: normalizeType((g as { grenade?: string }).grenade),
104
+ throwerSteamId64: (g as { throwerSteamId64?: string | null }).throwerSteamId64 ?? null,
105
+ throwerTeamKey: (g as { throwerTeamKey?: string | null }).throwerTeamKey ?? null,
106
+ effectTick: (g as { effectTick?: number }).effectTick ?? (g as { throwTick?: number }).throwTick ?? 0,
107
+ destroyTick: (g as { destroyTick?: number }).destroyTick ?? 0,
108
+ effectPosition,
109
+ zoneId: zone?.id ?? null,
110
+ zoneRole: zone?.role ?? null,
111
+ zoneBombsite: zone?.bombsite ?? null,
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ interface SiteArea {
118
+ bombsite: "a" | "b" | null;
119
+ areaId: number;
120
+ point: Vec3; // nav 质心(含 z),供 LOS/绕路使用
121
+ }
122
+
123
+ interface Sample {
124
+ tick: number;
125
+ pos: Vec3;
126
+ zoneId: string | null;
127
+ navAreaId: number | null;
128
+ health: number;
129
+ alive: boolean;
130
+ }
131
+
132
+ export function buildOfficialUtilitySpatial(
133
+ pkg: DemoPackage,
134
+ assets: SpatialAssets,
135
+ phases?: Map<number, RoundPhaseModel>,
136
+ ): Map<string, OfficialUtilitySpatial> {
137
+ const out = new Map<string, OfficialUtilitySpatial>();
138
+ if (!assets.zones) return out;
139
+ const samples = annotatePositions(pkg, assets);
140
+ if (samples.length === 0) return out;
141
+
142
+ const windows = buildUtilityWindows(pkg, assets);
143
+ const phaseModels = phases ?? inferRoundPhases(pkg);
144
+ const tickrate = pkg.match?.tickrate ?? pkg.manifest?.tickrate ?? 64;
145
+ const teamByPlayer = new Map(pkg.players.map((p) => [p.steamId64, p.teamKey]));
146
+ const navIndex = assets.nav ? buildNavIndex(assets.nav) : null;
147
+ const siteAreas = navIndex ? computeSiteAreas(assets, navIndex) : [];
148
+ const bvh = assets.visibility;
149
+ const tracks = buildTracks(samples); // round → steamId → Sample[](升序)
150
+
151
+ for (const w of windows) {
152
+ if (!w.throwerSteamId64 || !w.zoneId) continue;
153
+ const phase = phaseModels.get(w.roundNumber);
154
+ if (phase && !isOfficialScoringPhase(phaseAtTick(phase, w.effectTick))) continue;
155
+ const weight = roleWeight(w.zoneRole);
156
+ if (weight <= 0) continue;
157
+
158
+ const acc = getOrInit(out, w.throwerSteamId64, bvh != null);
159
+ const durationSeconds = effectiveDurationSeconds(w, tickrate);
160
+ const roundTracks = tracks.get(w.roundNumber);
161
+ const enemies = enemySamplesAt(roundTracks, teamByPlayer, w.throwerTeamKey, w.effectTick);
162
+ const hasTeammate = teammatePresent(roundTracks, teamByPlayer, w.throwerTeamKey, w.effectTick);
163
+
164
+ if (w.type === "smoke") {
165
+ const objective = nearestSite(siteAreas, w.effectPosition, navIndex);
166
+ // ① 隔离:nav 绕路代价(拓扑敏感 — 开阔图恒 0,已知模型缺陷,见文件头冻结说明,勿当 bug 修)
167
+ if (navIndex && objective && enemies.length > 0) {
168
+ const blocked = areasWithinRadius(navIndex, w.effectPosition, SMOKE_RADIUS);
169
+ const enemyArea = nearestAreaId(navIndex, clusterCentroid(enemies));
170
+ if (enemyArea != null) {
171
+ const detour = smokeDetourCost(navIndex, enemyArea, objective.areaId, blocked);
172
+ const isoFactor = Math.min(1, detour / DETOUR_FULL_UNITS);
173
+ if (isoFactor > 0) acc.actualSmokeIsolationSeconds += durationSeconds * isoFactor * weight;
174
+ }
175
+ }
176
+ // ② 视线封锁:tri-BVH 判定烟切断 enemy→site 的静态可见枪线
177
+ if (bvh && objective && hasTeammate) {
178
+ let deniedEnemies = 0;
179
+ for (const e of enemies) {
180
+ if (!staticLineOfSight(bvh, e.pos, objective.point)) continue; // 静态本就不可见 → 烟无功
181
+ if (segmentSphereIntersects(e.pos, objective.point, w.effectPosition, SMOKE_RADIUS)) deniedEnemies += 1;
182
+ }
183
+ if (deniedEnemies > 0) {
184
+ acc.actualSmokeSightlineDenialSeconds =
185
+ (acc.actualSmokeSightlineDenialSeconds ?? 0) + durationSeconds * Math.min(1, deniedEnemies * SIGHTLINE_PER_ENEMY) * weight;
186
+ }
187
+ }
188
+ } else if (FIRE_TYPES.has(w.type)) {
189
+ // ③ 路径延迟:火落要道且敌人在场
190
+ if (enemies.length > 0) acc.actualIncendiaryPathDelaySeconds += durationSeconds * weight;
191
+ // ④ 逼退:敌人火前在区、火后离开或掉血(放宽 1Hz 采样窗口)
192
+ acc.actualIncendiaryDisplacementEvents += displacementCount(roundTracks, teamByPlayer, w, tickrate) * weight;
193
+ }
194
+ }
195
+
196
+ // ⑤ 掩护穿越:队友穿越时被烟挡住敌方原本可见枪线(独立 pass,需 bvh)
197
+ if (bvh) {
198
+ for (const w of windows) {
199
+ if (w.type !== "smoke" || !w.throwerSteamId64 || !w.zoneId) continue;
200
+ const phase = phaseModels.get(w.roundNumber);
201
+ if (phase && !isOfficialScoringPhase(phaseAtTick(phase, w.effectTick))) continue;
202
+ if (roleWeight(w.zoneRole) <= 0) continue;
203
+ const acc = getOrInit(out, w.throwerSteamId64, true);
204
+ acc.actualSmokeProtectedCrossings =
205
+ (acc.actualSmokeProtectedCrossings ?? 0) + protectedCrossings(tracks.get(w.roundNumber), teamByPlayer, w, bvh, tickrate);
206
+ }
207
+ }
208
+
209
+ for (const acc of out.values()) round3InPlace(acc);
210
+ return out;
211
+ }
212
+
213
+ // —— 内部辅助 ——
214
+
215
+ function computeSiteAreas(assets: SpatialAssets, navIndex: NavIndex): SiteArea[] {
216
+ const out: SiteArea[] = [];
217
+ for (const z of assets.zones?.zones ?? []) {
218
+ if (z.role !== "site") continue;
219
+ const c = polygonCentroid(z);
220
+ // objective 选点启发式:site 质心 z=0 → 最近 nav 区。dust2 上该点常被墙挡,使 ② 视线封锁偏低。
221
+ // 已知缺陷(见文件头冻结说明),vision-based 重构时一并改。
222
+ const point3: Vec3 = { x: c.x, y: c.y, z: 0 };
223
+ const areaId = nearestAreaId(navIndex, point3);
224
+ if (areaId == null) continue;
225
+ const centroid = navIndex.byId.get(areaId)!.centroid;
226
+ out.push({ bombsite: z.bombsite ?? null, areaId, point: centroid });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function nearestSite(sites: SiteArea[], from: Vec3, navIndex: NavIndex | null): SiteArea | null {
232
+ if (sites.length === 0 || !navIndex) return null;
233
+ let best: SiteArea | null = null;
234
+ let bestD = Infinity;
235
+ for (const s of sites) {
236
+ const dx = s.point.x - from.x, dy = s.point.y - from.y;
237
+ const d = dx * dx + dy * dy;
238
+ if (d < bestD) { bestD = d; best = s; }
239
+ }
240
+ return best;
241
+ }
242
+
243
+ function buildTracks(samples: AnnotatedSample[]): Map<number, Map<string, Sample[]>> {
244
+ const out = new Map<number, Map<string, Sample[]>>();
245
+ for (const s of samples) {
246
+ let byPlayer = out.get(s.roundNumber);
247
+ if (!byPlayer) { byPlayer = new Map(); out.set(s.roundNumber, byPlayer); }
248
+ const arr = byPlayer.get(s.steamId64) ?? [];
249
+ arr.push({ tick: s.tick, pos: s.position, zoneId: s.zoneId, navAreaId: s.navAreaId, health: s.health, alive: s.alive });
250
+ byPlayer.set(s.steamId64, arr);
251
+ }
252
+ for (const byPlayer of out.values()) for (const arr of byPlayer.values()) arr.sort((a, b) => a.tick - b.tick);
253
+ return out;
254
+ }
255
+
256
+ function enemySamplesAt(
257
+ roundTracks: Map<string, Sample[]> | undefined,
258
+ teamByPlayer: Map<string, string>,
259
+ throwerTeam: string | null,
260
+ tick: number,
261
+ ): Sample[] {
262
+ const out: Sample[] = [];
263
+ if (!roundTracks) return out;
264
+ for (const [id, arr] of roundTracks) {
265
+ if (throwerTeam != null && teamByPlayer.get(id) === throwerTeam) continue;
266
+ const s = sampleAtOrBefore(arr, tick);
267
+ if (s && s.alive) out.push(s);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function teammatePresent(
273
+ roundTracks: Map<string, Sample[]> | undefined,
274
+ teamByPlayer: Map<string, string>,
275
+ throwerTeam: string | null,
276
+ tick: number,
277
+ ): boolean {
278
+ if (!roundTracks || throwerTeam == null) return false;
279
+ for (const [id, arr] of roundTracks) {
280
+ if (teamByPlayer.get(id) !== throwerTeam) continue;
281
+ const s = sampleAtOrBefore(arr, tick);
282
+ if (s && s.alive) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ /** 火焰逼退:敌人在 [eff-2s,eff] 处于火 zone,且 [eff,eff+4s] 内离开该 zone 或掉血。 */
288
+ function displacementCount(
289
+ roundTracks: Map<string, Sample[]> | undefined,
290
+ teamByPlayer: Map<string, string>,
291
+ w: UtilityWindow,
292
+ tickrate: number,
293
+ ): number {
294
+ if (!roundTracks || !w.zoneId) return 0;
295
+ const beforeLo = w.effectTick - DISPLACE_BEFORE_SECONDS * tickrate;
296
+ const afterHi = w.effectTick + DISPLACE_AFTER_SECONDS * tickrate;
297
+ let count = 0;
298
+ for (const [id, arr] of roundTracks) {
299
+ if (w.throwerTeamKey != null && teamByPlayer.get(id) === w.throwerTeamKey) continue;
300
+ const inZoneBefore = arr.some((s) => s.tick >= beforeLo && s.tick <= w.effectTick && s.zoneId === w.zoneId);
301
+ if (!inZoneBefore) continue;
302
+ const hpBefore = lastBefore(arr, w.effectTick)?.health ?? 100;
303
+ const displaced = arr.some(
304
+ (s) => s.tick > w.effectTick && s.tick <= afterHi && (s.zoneId !== w.zoneId || s.health < hpBefore),
305
+ );
306
+ if (displaced) count += 1;
307
+ }
308
+ return count;
309
+ }
310
+
311
+ /** 掩护穿越:队友在窗口内换 zone(穿越),且某敌人本可静态看到其穿越位、但被烟挡住。 */
312
+ function protectedCrossings(
313
+ roundTracks: Map<string, Sample[]> | undefined,
314
+ teamByPlayer: Map<string, string>,
315
+ w: UtilityWindow,
316
+ bvh: TriangleBvh,
317
+ tickrate: number,
318
+ ): number {
319
+ if (!roundTracks) return 0;
320
+ const lo = w.effectTick;
321
+ const hi = w.destroyTick > w.effectTick ? w.destroyTick : w.effectTick + SMOKE_DURATION_SECONDS * tickrate;
322
+ let count = 0;
323
+ for (const [id, arr] of roundTracks) {
324
+ if (w.throwerTeamKey != null && teamByPlayer.get(id) !== w.throwerTeamKey) continue; // 只看队友
325
+ const inWin = arr.filter((s) => s.tick >= lo && s.tick <= hi && s.alive);
326
+ if (inWin.length < 2) continue;
327
+ const crossed = inWin.some((s, i) => i > 0 && s.zoneId !== inWin[i - 1]!.zoneId);
328
+ if (!crossed) continue;
329
+ const mid = inWin[Math.floor(inWin.length / 2)]!;
330
+ // 某敌人本可静态看到 mid,但烟挡住
331
+ let protectedHere = false;
332
+ for (const [eid, earr] of roundTracks) {
333
+ if (teamByPlayer.get(eid) === w.throwerTeamKey) continue;
334
+ const e = sampleAtOrBefore(earr, mid.tick);
335
+ if (!e || !e.alive) continue;
336
+ if (staticLineOfSight(bvh, e.pos, mid.pos) && segmentSphereIntersects(e.pos, mid.pos, w.effectPosition, SMOKE_RADIUS)) {
337
+ protectedHere = true;
338
+ break;
339
+ }
340
+ }
341
+ if (protectedHere) count += 1;
342
+ }
343
+ return count;
344
+ }
345
+
346
+ function clusterCentroid(samples: Sample[]): Vec3 {
347
+ let x = 0, y = 0, z = 0;
348
+ for (const s of samples) { x += s.pos.x; y += s.pos.y; z += s.pos.z; }
349
+ const n = Math.max(1, samples.length);
350
+ return { x: x / n, y: y / n, z: z / n };
351
+ }
352
+
353
+ function sampleAtOrBefore(arr: Sample[], tick: number): Sample | null {
354
+ let best: Sample | null = null;
355
+ for (const s of arr) { if (s.tick > tick) break; best = s; }
356
+ return best;
357
+ }
358
+
359
+ function lastBefore(arr: Sample[], tick: number): Sample | null {
360
+ return sampleAtOrBefore(arr, tick);
361
+ }
362
+
363
+ function getOrInit(map: Map<string, OfficialUtilitySpatial>, id: string, losAvailable: boolean): OfficialUtilitySpatial {
364
+ let v = map.get(id);
365
+ if (!v) {
366
+ v = {
367
+ actualIncendiaryDisplacementEvents: 0,
368
+ actualSmokeIsolationSeconds: 0,
369
+ actualIncendiaryPathDelaySeconds: 0,
370
+ actualSmokeProtectedCrossings: losAvailable ? 0 : null,
371
+ actualSmokeSightlineDenialSeconds: losAvailable ? 0 : null,
372
+ };
373
+ map.set(id, v);
374
+ }
375
+ return v;
376
+ }
377
+
378
+ function round3InPlace(acc: OfficialUtilitySpatial) {
379
+ acc.actualIncendiaryDisplacementEvents = round3(acc.actualIncendiaryDisplacementEvents);
380
+ acc.actualSmokeIsolationSeconds = round3(acc.actualSmokeIsolationSeconds);
381
+ acc.actualIncendiaryPathDelaySeconds = round3(acc.actualIncendiaryPathDelaySeconds);
382
+ if (acc.actualSmokeProtectedCrossings != null) acc.actualSmokeProtectedCrossings = round3(acc.actualSmokeProtectedCrossings);
383
+ if (acc.actualSmokeSightlineDenialSeconds != null) acc.actualSmokeSightlineDenialSeconds = round3(acc.actualSmokeSightlineDenialSeconds);
384
+ }
385
+
386
+ function normalizeType(g: string | undefined): GrenadeType {
387
+ switch (g) {
388
+ case "smoke": case "molotov": case "incendiary": case "flash": case "he": case "decoy": return g;
389
+ default: return "unknown";
390
+ }
391
+ }
392
+
393
+ function toVec3(p: Partial<Vec3> | undefined): Vec3 {
394
+ return { x: p?.x ?? 0, y: p?.y ?? 0, z: p?.z ?? 0 };
395
+ }
396
+
397
+ function round3(v: number): number {
398
+ return Math.round(v * 1000) / 1000;
399
+ }
@@ -0,0 +1,151 @@
1
+ import { calloutCn, classifyTacticalLocation, positionGroupOf, type DefaultPositionSide } from "@cs2dak/maps";
2
+ import type {
3
+ OpeningPattern,
4
+ OpeningPressureEvent,
5
+ PlayerTacticalSegment,
6
+ TacticalFormationSnapshot,
7
+ TacticalFrameSample,
8
+ TacticalRegionCounts,
9
+ } from "./types.js";
10
+
11
+ function emptyRegionCounts(): TacticalRegionCounts {
12
+ return { a: 0, b: 0, mid: 0, unknown: 0 };
13
+ }
14
+
15
+ export function deriveOpeningPressure(
16
+ segments: readonly PlayerTacticalSegment[],
17
+ options: {
18
+ mapName: string;
19
+ side: DefaultPositionSide;
20
+ startTick: number;
21
+ endTick: number;
22
+ minDwellSec?: number;
23
+ },
24
+ ): OpeningPressureEvent[] {
25
+ const opposingSide: DefaultPositionSide = options.side === "t" ? "ct" : "t";
26
+ const minDwellSec = options.minDwellSec ?? 1;
27
+ return segments
28
+ .filter((segment) =>
29
+ segment.side === options.side
30
+ && segment.startTick <= options.endTick
31
+ && segment.endTick >= options.startTick
32
+ && segment.durationSec >= minDwellSec
33
+ && segment.positionGroupId == null,
34
+ )
35
+ .map((segment): OpeningPressureEvent | null => {
36
+ const opposingPositionGroupId = positionGroupOf(options.mapName, opposingSide, segment.callout);
37
+ if (!opposingPositionGroupId && !segment.primaryRegion) return null;
38
+ return {
39
+ playerIndex: segment.playerIndex,
40
+ side: segment.side,
41
+ tick: segment.startTick,
42
+ endTick: segment.endTick,
43
+ callout: segment.callout,
44
+ calloutLabel: calloutCn(options.mapName, segment.callout) || segment.callout,
45
+ primaryRegion: segment.primaryRegion,
46
+ kind: opposingPositionGroupId ? "deep" : "forward",
47
+ opposingPositionGroupId,
48
+ evidence: segment.evidence,
49
+ };
50
+ })
51
+ .filter((event): event is OpeningPressureEvent => event != null)
52
+ .sort((a, b) => a.tick - b.tick || a.playerIndex - b.playerIndex);
53
+ }
54
+
55
+ export function buildFormationTimeline(
56
+ samples: readonly TacticalFrameSample[],
57
+ options: { mapName: string },
58
+ ): TacticalFormationSnapshot[] {
59
+ const groups = new Map<string, TacticalFrameSample[]>();
60
+ for (const sample of samples) {
61
+ if (!sample.alive) continue;
62
+ const key = `${sample.tick}:${sample.side}`;
63
+ const rows = groups.get(key) ?? [];
64
+ rows.push(sample);
65
+ groups.set(key, rows);
66
+ }
67
+ return [...groups.values()]
68
+ .map((rows) => {
69
+ const first = rows[0]!;
70
+ const regionCounts = emptyRegionCounts();
71
+ const positionGroupCounts: Record<string, number> = {};
72
+ const playerLocations = rows.map((sample) => {
73
+ const location = classifyTacticalLocation(options.mapName, sample.side, sample.callout);
74
+ if (location.primaryRegion) regionCounts[location.primaryRegion] += 1;
75
+ else regionCounts.unknown += 1;
76
+ if (location.positionGroupId) {
77
+ positionGroupCounts[location.positionGroupId] = (positionGroupCounts[location.positionGroupId] ?? 0) + 1;
78
+ }
79
+ return {
80
+ playerIndex: sample.playerIndex,
81
+ callout: sample.callout,
82
+ primaryRegion: location.primaryRegion,
83
+ positionGroupId: location.positionGroupId,
84
+ };
85
+ });
86
+ return { tick: first.tick, side: first.side, regionCounts, positionGroupCounts, playerLocations };
87
+ })
88
+ .sort((a, b) => a.tick - b.tick || a.side.localeCompare(b.side));
89
+ }
90
+
91
+ function overlapTicks(segment: PlayerTacticalSegment, startTick: number, endTick: number): number {
92
+ return Math.max(0, Math.min(segment.endTick, endTick) - Math.max(segment.startTick, startTick));
93
+ }
94
+
95
+ function spreadOf(counts: TacticalRegionCounts): OpeningPattern["spread"] {
96
+ const occupied = [counts.a, counts.mid, counts.b].filter((count) => count > 0).length;
97
+ if (occupied === 0) return "unknown";
98
+ if (occupied === 1) return "stacked";
99
+ if (occupied === 2) return "split";
100
+ return "balanced";
101
+ }
102
+
103
+ export function deriveOpeningPattern(
104
+ segments: readonly PlayerTacticalSegment[],
105
+ options: { side: DefaultPositionSide; startTick: number; endTick: number; minDefaultDwellSec?: number },
106
+ ): OpeningPattern {
107
+ const byPlayer = new Map<number, PlayerTacticalSegment[]>();
108
+ for (const segment of segments) {
109
+ if (segment.side !== options.side || segment.endTick < options.startTick || segment.startTick > options.endTick) continue;
110
+ const rows = byPlayer.get(segment.playerIndex) ?? [];
111
+ rows.push(segment);
112
+ byPlayer.set(segment.playerIndex, rows);
113
+ }
114
+
115
+ const minDefaultDwellSec = options.minDefaultDwellSec ?? 1;
116
+ const selected = [...byPlayer.values()].map((rows) => rows.sort((a, b) => {
117
+ const aDefault = a.positionGroupId != null && a.durationSec >= minDefaultDwellSec ? 1 : 0;
118
+ const bDefault = b.positionGroupId != null && b.durationSec >= minDefaultDwellSec ? 1 : 0;
119
+ const aKnown = a.primaryRegion != null ? 1 : 0;
120
+ const bKnown = b.primaryRegion != null ? 1 : 0;
121
+ return bDefault - aDefault
122
+ || bKnown - aKnown
123
+ || overlapTicks(b, options.startTick, options.endTick) - overlapTicks(a, options.startTick, options.endTick)
124
+ || a.startTick - b.startTick;
125
+ })[0]!);
126
+ const regionCounts = emptyRegionCounts();
127
+ const positionGroupCounts: Record<string, number> = {};
128
+ for (const segment of selected) {
129
+ if (segment.primaryRegion) regionCounts[segment.primaryRegion] += 1;
130
+ else regionCounts.unknown += 1;
131
+ if (segment.positionGroupId) {
132
+ positionGroupCounts[segment.positionGroupId] = (positionGroupCounts[segment.positionGroupId] ?? 0) + 1;
133
+ }
134
+ }
135
+ const spread = spreadOf(regionCounts);
136
+ const sideLabel = options.side.toUpperCase();
137
+ const coarseSignature = `${sideLabel}:${regionCounts.a}A-${regionCounts.mid}MID-${regionCounts.b}B:${spread}`;
138
+ const groups = Object.entries(positionGroupCounts)
139
+ .sort(([a], [b]) => a.localeCompare(b))
140
+ .map(([id, count]) => `${id}:${count}`)
141
+ .join("|");
142
+ return {
143
+ side: options.side,
144
+ regionCounts,
145
+ positionGroupCounts,
146
+ spread,
147
+ coarseSignature,
148
+ detailedSignature: `${sideLabel}:${groups || "-"}`,
149
+ evidence: selected.map((segment) => segment.evidence),
150
+ };
151
+ }
@@ -0,0 +1,16 @@
1
+ export * from "./types.js";
2
+ export * from "./segments.js";
3
+ export * from "./formations.js";
4
+ export {
5
+ TACTICAL_FACT_VERSION,
6
+ extractTacticalRoundFacts,
7
+ } from "./round-facts.js";
8
+ export type {
9
+ C4RouteFact,
10
+ ExecuteBucket,
11
+ SiteEntryFact,
12
+ SiteEntryOccurrence,
13
+ TacticalGrenadeOccurrence,
14
+ TacticalPlantFact,
15
+ TacticalRoundFact,
16
+ } from "./round-facts.js";
@@ -0,0 +1,96 @@
1
+ import { decodeDelta, type DemoPackage, type Side, type TeamKey } from "@cs2dak/contract";
2
+ import { calloutNear, type CalloutGrid, type Vec3 } from "@cs2dak/maps";
3
+
4
+ /**
5
+ * A short-lived decoded replay round shared by replay-derived fact extractors.
6
+ * It is deliberately not a public persisted shape: callers consume compact facts only.
7
+ */
8
+ export interface ReplayRoundTrack {
9
+ playerIndex: number;
10
+ teamKey: TeamKey | null;
11
+ side: Side | null;
12
+ steamId64: string | null;
13
+ x: number[];
14
+ y: number[];
15
+ z: number[];
16
+ flags: number[];
17
+ place: number[];
18
+ weapon: number[];
19
+ }
20
+
21
+ export interface ReplayRoundContext {
22
+ round: DemoPackage["rounds"][number];
23
+ startTick: number;
24
+ tickStep: number;
25
+ frameCount: number;
26
+ placeDict: readonly string[];
27
+ weaponDict: readonly string[];
28
+ tracks: ReplayRoundTrack[];
29
+ }
30
+
31
+ export function createReplayRoundContext(
32
+ pkg: DemoPackage,
33
+ round: DemoPackage["rounds"][number],
34
+ ): ReplayRoundContext | null {
35
+ const replayRound = pkg.replay?.rounds.find((row) => row.roundNumber === round.roundNumber);
36
+ if (!pkg.replay || !replayRound) return null;
37
+ const scale = pkg.replay.meta.coordScale;
38
+ return {
39
+ round,
40
+ startTick: replayRound.startTick,
41
+ tickStep: replayRound.tickStep,
42
+ frameCount: replayRound.frameCount,
43
+ placeDict: pkg.replay.placeDict,
44
+ weaponDict: pkg.replay.weaponDict,
45
+ tracks: replayRound.players.map((track) => {
46
+ const player = pkg.players[track.playerIndex];
47
+ const teamKey = player?.teamKey ?? null;
48
+ const side = teamKey === "teamA" ? round.teamASide : teamKey === "teamB" ? round.teamBSide : null;
49
+ return {
50
+ playerIndex: track.playerIndex,
51
+ teamKey,
52
+ side,
53
+ steamId64: player?.steamId64 ?? null,
54
+ x: decodeDelta(track.x).map((value) => value * scale),
55
+ y: decodeDelta(track.y).map((value) => value * scale),
56
+ z: decodeDelta(track.z).map((value) => value * scale),
57
+ flags: track.flags,
58
+ place: track.place,
59
+ weapon: track.weapon,
60
+ };
61
+ }),
62
+ };
63
+ }
64
+
65
+ export function createReplayRoundContexts(pkg: DemoPackage): Map<number, ReplayRoundContext> {
66
+ const contexts = new Map<number, ReplayRoundContext>();
67
+ for (const round of pkg.rounds) {
68
+ const context = createReplayRoundContext(pkg, round);
69
+ if (context) contexts.set(round.roundNumber, context);
70
+ }
71
+ return contexts;
72
+ }
73
+
74
+ export function replayTickAt(context: ReplayRoundContext, frameIndex: number): number {
75
+ return context.startTick + frameIndex * context.tickStep;
76
+ }
77
+
78
+ export function replayPointAt(track: ReplayRoundTrack, frameIndex: number): Vec3 {
79
+ return { x: track.x[frameIndex] ?? 0, y: track.y[frameIndex] ?? 0, z: track.z[frameIndex] ?? 0 };
80
+ }
81
+
82
+ export function replayCalloutAt(
83
+ context: ReplayRoundContext,
84
+ track: ReplayRoundTrack,
85
+ frameIndex: number,
86
+ grid: CalloutGrid | null,
87
+ ): string | null {
88
+ const place = context.placeDict[track.place[frameIndex] ?? -1] ?? null;
89
+ if (place) return place;
90
+ if (!grid) return null;
91
+ return calloutNear(grid, replayPointAt(track, frameIndex), { horizontalRadius: 20, verticalRadius: 40 })?.callout ?? null;
92
+ }
93
+
94
+ export function replayWeaponAt(context: ReplayRoundContext, track: ReplayRoundTrack, frameIndex: number): string | null {
95
+ return context.weaponDict[track.weapon[frameIndex] ?? -1] ?? null;
96
+ }