@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
@@ -0,0 +1,395 @@
1
+ /**
2
+ * 雷达覆盖场计算 —— 把一场 DemoPackage 的 replay 榨成「按队归属」的加性场贡献。
3
+ *
4
+ * 几何判定复用地图侧 `staticLineOfSight` 与共享眼高/靶高常量;屏幕可见与准星覆盖只在
5
+ * 相机坐标中分口径,避免重算昂贵 LOS。
6
+ *
7
+ * 每场产出两份贡献(teamA / teamB),各自:ct* 只填该队作 CT 的回合,
8
+ * t* 只填该队作 T 的回合。于是:
9
+ * - 联赛基线 = 所有贡献相加(每回合的 CT 数据落 CT 队那份、T 数据落 T 队那份,不重复)
10
+ * - 单队场 = 该队所有贡献相加(该队 CT 回合看哪、T 回合站哪)
11
+ * denom 按 side 分(每回合恰一个 CT 一个 T 队),归一化 = 计数 / 对应 side denom。
12
+ */
13
+ import type { DemoPackage, PackageGrenade, RadarField, RadarFieldBase } from "@cs2dak/contract";
14
+ import { RADAR_FIELD_SCHEMA_VERSION, RADAR_FIELD_MAX_SEC, RADAR_FIELD_BASES, decodeDelta } from "@cs2dak/contract";
15
+ import {
16
+ type RadarFieldGridIndex,
17
+ type Vec3,
18
+ type TriangleBvh,
19
+ staticLineOfSight,
20
+ radarFieldCellAt,
21
+ MAP_CALIBRATION_VERSION,
22
+ } from "@cs2dak/maps";
23
+ import {
24
+ EYE_HEIGHT,
25
+ TARGET_HEIGHT,
26
+ } from "./duel-window.js";
27
+
28
+ /** 算法参数指纹;锥角/采样率/格大小/眼高/MAX_DIST 任一变更即 +1,使旧缓存场失效。 */
29
+ export const RADAR_FIELD_VERSION = 2;
30
+
31
+ /** 视野判定最大距离(世界单位),超出直接跳过 LOS。 */
32
+ const MAX_DIST = 4096;
33
+ const MAX_DIST_SQ = MAX_DIST * MAX_DIST;
34
+ const AIM_CONE_HALF_DEGREES = 30;
35
+ const AIM_CONE_COS = Math.cos((AIM_CONE_HALF_DEGREES * Math.PI) / 180);
36
+ const SCREEN_4X3_TAN_H = Math.tan((45 * Math.PI) / 180);
37
+ const SCREEN_4X3_TAN_V = Math.tan((36.87 * Math.PI) / 180);
38
+ const SMOKE_RADIUS = 144;
39
+ const SOUND_RADIUS = 800;
40
+ const SOUND_RADIUS_SQ = SOUND_RADIUS * SOUND_RADIUS;
41
+ const GUN_ECONOMY = new Set(["full", "conversion"]);
42
+ const SMOKE_TYPES = new Set(["smoke", "smokegrenade"]);
43
+ const EMPTY_SMOKES: SmokeGrenade[] = [];
44
+
45
+ const FIELD_BASES = RADAR_FIELD_BASES;
46
+
47
+ interface Track {
48
+ x: number[];
49
+ y: number[];
50
+ z: number[];
51
+ yaw: number[];
52
+ pitch: number[];
53
+ hp: number[];
54
+ flash: number[];
55
+ }
56
+
57
+ interface ActiveViewer {
58
+ eye: Vec3;
59
+ fx: number;
60
+ fy: number;
61
+ fz: number;
62
+ rx: number;
63
+ ry: number;
64
+ ux: number;
65
+ uy: number;
66
+ uz: number;
67
+ }
68
+
69
+ type SmokeGrenade = PackageGrenade & { effectPosition: Vec3 };
70
+
71
+ interface Contribution {
72
+ team: string;
73
+ matchId: string;
74
+ roundCount: number;
75
+ denomCt: Int32Array;
76
+ denomT: Int32Array;
77
+ fields: Record<RadarFieldBase, Int32Array[]>;
78
+ }
79
+
80
+ function makeFieldRows(maxSec: number, nCells: number): Int32Array[] {
81
+ return Array.from({ length: maxSec }, () => new Int32Array(nCells));
82
+ }
83
+
84
+ function makeFields(maxSec: number, nCells: number): Record<RadarFieldBase, Int32Array[]> {
85
+ const fields = {} as Record<RadarFieldBase, Int32Array[]>;
86
+ for (const base of FIELD_BASES) fields[base] = makeFieldRows(maxSec, nCells);
87
+ return fields;
88
+ }
89
+
90
+ function makeContribution(team: string, matchId: string, maxSec: number, nCells: number): Contribution {
91
+ return {
92
+ team,
93
+ matchId,
94
+ roundCount: 0,
95
+ denomCt: new Int32Array(maxSec),
96
+ denomT: new Int32Array(maxSec),
97
+ fields: makeFields(maxSec, nCells),
98
+ };
99
+ }
100
+
101
+ function decodeTrack(t: { x: number[]; y: number[]; z: number[]; yaw: number[]; pitch: number[]; hp: number[]; flash: number[] }, coordScale: number, angleScale: number): Track {
102
+ const cum = (arr: number[], by: number) => decodeDelta(arr).map((v) => v / by);
103
+ return {
104
+ x: cum(t.x, coordScale),
105
+ y: cum(t.y, coordScale),
106
+ z: cum(t.z, coordScale),
107
+ yaw: cum(t.yaw, angleScale),
108
+ pitch: cum(t.pitch, angleScale),
109
+ hp: t.hp,
110
+ flash: t.flash,
111
+ };
112
+ }
113
+
114
+ function activeViewersAt(viewers: Track[], i: number): ActiveViewer[] {
115
+ const out: ActiveViewer[] = [];
116
+ for (const v of viewers) {
117
+ if ((v.hp[i] ?? 0) <= 0 || (v.flash[i] ?? 0) > 0) continue;
118
+ const yaw = (v.yaw[i]! * Math.PI) / 180;
119
+ const pitch = (v.pitch[i]! * Math.PI) / 180;
120
+ const cp = Math.cos(pitch);
121
+ const fx = cp * Math.cos(yaw);
122
+ const fy = cp * Math.sin(yaw);
123
+ const fz = -Math.sin(pitch);
124
+ const rx = -Math.sin(yaw);
125
+ const ry = Math.cos(yaw);
126
+ out.push({
127
+ eye: { x: v.x[i]!, y: v.y[i]!, z: v.z[i]! + EYE_HEIGHT },
128
+ fx,
129
+ fy,
130
+ fz,
131
+ rx,
132
+ ry,
133
+ ux: -fz * ry,
134
+ uy: fz * rx,
135
+ uz: fx * ry - fy * rx,
136
+ });
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function activeSmokesAt(smokes: SmokeGrenade[], tick: number): SmokeGrenade[] {
142
+ let out: SmokeGrenade[] | null = null;
143
+ for (const grenade of smokes) {
144
+ if (grenade.effectTick <= tick && (grenade.destroyTick == null || tick <= grenade.destroyTick)) {
145
+ (out ??= []).push(grenade);
146
+ }
147
+ }
148
+ return out ?? EMPTY_SMOKES;
149
+ }
150
+
151
+ function pointToSegmentDistance(point: Vec3, a: Vec3, b: Vec3): number {
152
+ const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z;
153
+ const apx = point.x - a.x, apy = point.y - a.y, apz = point.z - a.z;
154
+ const denom = abx * abx + aby * aby + abz * abz;
155
+ const t = denom < 1e-6 ? 0 : Math.max(0, Math.min(1, (apx * abx + apy * aby + apz * abz) / denom));
156
+ const cx = a.x + abx * t, cy = a.y + aby * t, cz = a.z + abz * t;
157
+ return Math.hypot(point.x - cx, point.y - cy, point.z - cz);
158
+ }
159
+
160
+ function smokeBlocksActiveRay(smokes: SmokeGrenade[], from: Vec3, to: Vec3): boolean {
161
+ for (const smoke of smokes) {
162
+ if (pointToSegmentDistance(smoke.effectPosition, from, to) <= SMOKE_RADIUS) return true;
163
+ }
164
+ return false;
165
+ }
166
+
167
+ export interface BuildMatchRadarFieldOptions {
168
+ matchId: string;
169
+ grid: RadarFieldGridIndex;
170
+ /** 静态墙体 BVH;缺失时跳过 LOS(triAvailability=none,只保留锥+烟)。 */
171
+ bvh?: TriangleBvh | null;
172
+ /** gun = 长枪局(双方 full/conversion);all = 全部回合。默认 gun。 */
173
+ economy?: "gun" | "all";
174
+ }
175
+
176
+ /**
177
+ * 算一场的雷达场贡献,返回 [teamA, teamB] 两份(各为独立 RadarField,scope.kind=team)。
178
+ * 无 replay 时返回 []。studio 缓存这两份并跨场聚合。
179
+ */
180
+ export function buildMatchRadarField(pkg: DemoPackage, options: BuildMatchRadarFieldOptions): RadarField[] {
181
+ const { matchId, grid, bvh = null, economy = "gun" } = options;
182
+ const replay = pkg.replay;
183
+ if (!replay || replay.rounds.length === 0) return [];
184
+
185
+ const maxSec = RADAR_FIELD_MAX_SEC;
186
+ const nCells = grid.cells.length;
187
+ const { coordScale, angleScale, sampleRate, tickrate } = replay.meta;
188
+
189
+ // 预算每格的坐标;每个采样帧会重复扫,避免在热循环里拆 tuple / 建对象。
190
+ const targets: Vec3[] = new Array(nCells);
191
+ const targetX = new Float64Array(nCells);
192
+ const targetY = new Float64Array(nCells);
193
+ const targetZ = new Float64Array(nCells);
194
+ for (let g = 0; g < nCells; g++) {
195
+ const [x, y, z] = grid.cells[g]!;
196
+ const target = { x, y, z: z + TARGET_HEIGHT };
197
+ targets[g] = target;
198
+ targetX[g] = target.x;
199
+ targetY[g] = target.y;
200
+ targetZ[g] = target.z;
201
+ }
202
+ const aimConeCosSq = AIM_CONE_COS * AIM_CONE_COS;
203
+ const presenceSeen = new Uint32Array(nCells);
204
+ const soundAliveX = new Float64Array(Math.max(1, pkg.players.length));
205
+ const soundAliveY = new Float64Array(Math.max(1, pkg.players.length));
206
+ let presenceSeq = 0;
207
+
208
+ const teamKeyByIndex = pkg.players.map((p) => p.teamKey);
209
+ const roundMeta = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
210
+ const smokesByRound = new Map<number, SmokeGrenade[]>();
211
+ for (const grenade of pkg.grenades ?? []) {
212
+ if (!SMOKE_TYPES.has(grenade.grenade) || !grenade.effectPosition) continue;
213
+ const bucket = smokesByRound.get(grenade.roundNumber) ?? [];
214
+ bucket.push(grenade as SmokeGrenade);
215
+ smokesByRound.set(grenade.roundNumber, bucket);
216
+ }
217
+ const teamNameByKey = { teamA: pkg.match.teamA.name ?? "Team A", teamB: pkg.match.teamB.name ?? "Team B" } as const;
218
+
219
+ const contributions: Record<"teamA" | "teamB", Contribution> = {
220
+ teamA: makeContribution(teamNameByKey.teamA, matchId, maxSec, nCells),
221
+ teamB: makeContribution(teamNameByKey.teamB, matchId, maxSec, nCells),
222
+ };
223
+
224
+ const markVision = (viewers: Track[], i: number, screenOut: Int32Array, aimOut: Int32Array, activeSmokes: SmokeGrenade[]) => {
225
+ const activeViewers = activeViewersAt(viewers, i);
226
+ if (activeViewers.length === 0) return;
227
+ for (let g = 0; g < nCells; g++) {
228
+ const tx = targetX[g]!;
229
+ const ty = targetY[g]!;
230
+ const tz = targetZ[g]!;
231
+ for (const viewer of activeViewers) {
232
+ const eye = viewer.eye;
233
+ const dx = tx - eye.x;
234
+ const dy = ty - eye.y;
235
+ const dz = tz - eye.z;
236
+ const distSq = dx * dx + dy * dy + dz * dz;
237
+ if (distSq <= 1e-6 || distSq > MAX_DIST_SQ) continue;
238
+ const forward = viewer.fx * dx + viewer.fy * dy + viewer.fz * dz;
239
+ if (forward <= 0) continue;
240
+ const screenX = viewer.rx * dx + viewer.ry * dy;
241
+ if (Math.abs(screenX) > forward * SCREEN_4X3_TAN_H) continue;
242
+ const screenY = viewer.ux * dx + viewer.uy * dy + viewer.uz * dz;
243
+ if (Math.abs(screenY) > forward * SCREEN_4X3_TAN_V) continue;
244
+ const target = targets[g]!;
245
+ if (activeSmokes.length > 0 && smokeBlocksActiveRay(activeSmokes, eye, target)) continue;
246
+ if (bvh && !staticLineOfSight(bvh, eye, target)) continue;
247
+ screenOut[g]! += 1;
248
+ if (forward * forward >= aimConeCosSq * distSq) aimOut[g]! += 1;
249
+ break;
250
+ }
251
+ }
252
+ };
253
+
254
+ const markSoundRisk = (listeners: Track[], i: number, out: Int32Array) => {
255
+ let alive = 0;
256
+ for (const p of listeners) {
257
+ if ((p.hp[i] ?? 0) <= 0) continue;
258
+ soundAliveX[alive] = p.x[i]!;
259
+ soundAliveY[alive] = p.y[i]!;
260
+ alive += 1;
261
+ }
262
+ if (alive === 0) return;
263
+ for (let g = 0; g < nCells; g++) {
264
+ const x = targetX[g]!;
265
+ const y = targetY[g]!;
266
+ for (let j = 0; j < alive; j++) {
267
+ const lx = soundAliveX[j]!;
268
+ const ly = soundAliveY[j]!;
269
+ const dx = x - lx, dy = y - ly;
270
+ if (dx * dx + dy * dy <= SOUND_RADIUS_SQ) {
271
+ out[g]! += 1;
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ };
277
+
278
+ const markPresence = (players: Track[], i: number, out: Int32Array) => {
279
+ const stamp = ++presenceSeq;
280
+ for (const p of players) {
281
+ if ((p.hp[i] ?? 0) <= 0) continue;
282
+ const idx = radarFieldCellAt(grid, p.x[i] ?? 0, p.y[i] ?? 0);
283
+ if (idx >= 0 && presenceSeen[idx] !== stamp) {
284
+ presenceSeen[idx] = stamp;
285
+ out[idx]! += 1;
286
+ }
287
+ }
288
+ };
289
+
290
+ for (const rr of replay.rounds) {
291
+ const meta = roundMeta.get(rr.roundNumber);
292
+ if (!meta) continue;
293
+ if (economy === "gun" && !(GUN_ECONOMY.has(meta.teamAEconomy) && GUN_ECONOMY.has(meta.teamBEconomy))) continue;
294
+
295
+ const ctKey = meta.teamASide === "ct" ? "teamA" : "teamB";
296
+ const tKey = ctKey === "teamA" ? "teamB" : "teamA";
297
+ const ctContrib = contributions[ctKey];
298
+ const tContrib = contributions[tKey];
299
+ ctContrib.roundCount += 1;
300
+ tContrib.roundCount += 1;
301
+
302
+ const cts: Track[] = [];
303
+ const ts: Track[] = [];
304
+ for (const track of rr.players) {
305
+ const decoded = decodeTrack(track, coordScale, angleScale);
306
+ (teamKeyByIndex[track.playerIndex] === ctKey ? cts : ts).push(decoded);
307
+ }
308
+ const frames = cts[0]?.x.length ?? ts[0]?.x.length ?? 0;
309
+ for (let i = 0; i < frames; i += sampleRate) {
310
+ const tick = rr.startTick + i * rr.tickStep;
311
+ const sec = Math.floor((tick - meta.freezeEndTick) / tickrate);
312
+ if (sec < 0 || sec >= maxSec) continue;
313
+ const activeSmokes = activeSmokesAt(smokesByRound.get(rr.roundNumber) ?? EMPTY_SMOKES, tick);
314
+ ctContrib.denomCt[sec]! += 1;
315
+ tContrib.denomT[sec]! += 1;
316
+ markVision(cts, i, ctContrib.fields.ctVis[sec]!, ctContrib.fields.ctAim[sec]!, activeSmokes);
317
+ markVision(ts, i, tContrib.fields.tVis[sec]!, tContrib.fields.tAim[sec]!, activeSmokes);
318
+ markPresence(cts, i, ctContrib.fields.ctPres[sec]!);
319
+ markPresence(ts, i, tContrib.fields.tPres[sec]!);
320
+ markSoundRisk(ts, i, ctContrib.fields.ctSound[sec]!);
321
+ markSoundRisk(cts, i, tContrib.fields.tSound[sec]!);
322
+ }
323
+ }
324
+
325
+ const triAvailability = bvh ? "full" : "none";
326
+ return (["teamA", "teamB"] as const).map((key) => {
327
+ const c = contributions[key];
328
+ const field: RadarField = {
329
+ schemaVersion: RADAR_FIELD_SCHEMA_VERSION,
330
+ computeVersion: RADAR_FIELD_VERSION,
331
+ mapName: pkg.match.mapName,
332
+ calibrationVersion: MAP_CALIBRATION_VERSION,
333
+ triAvailability,
334
+ scope: { kind: "team", team: c.team, economy, roundCount: c.roundCount, matchIds: [matchId] },
335
+ grid: { cellSize: grid.cellSize, cells: grid.cells },
336
+ maxSec,
337
+ denomCt: c.denomCt,
338
+ denomT: c.denomT,
339
+ fields: c.fields,
340
+ };
341
+ return field;
342
+ });
343
+ }
344
+
345
+ /** 把多份场加性合并成一个 scope 场(联赛基线或单队)。grid/maxSec 必须一致。 */
346
+ export function aggregateRadarFields(
347
+ fields: RadarField[],
348
+ scope: { kind: "league" | "team"; team: string | null }
349
+ ): RadarField | null {
350
+ if (fields.length === 0) return null;
351
+ const first = fields[0]!;
352
+ const maxSec = first.maxSec;
353
+ const nCells = first.grid.cells.length;
354
+ const denomCt = new Int32Array(maxSec);
355
+ const denomT = new Int32Array(maxSec);
356
+ const out = makeFields(maxSec, nCells);
357
+ const matchIds = new Set<string>();
358
+ const countedForRounds = new Set<string>();
359
+ let roundCount = 0;
360
+ let economy = first.scope.economy;
361
+
362
+ for (const f of fields) {
363
+ economy = f.scope.economy;
364
+ for (let s = 0; s < maxSec; s++) {
365
+ denomCt[s]! += f.denomCt[s]!;
366
+ denomT[s]! += f.denomT[s]!;
367
+ for (const base of FIELD_BASES) {
368
+ const dst = out[base][s]!;
369
+ const src = f.fields[base][s]!;
370
+ for (let g = 0; g < nCells; g++) dst[g]! += src[g]!;
371
+ }
372
+ }
373
+ // roundCount 按 matchId 去重计:联赛求和时同场两份贡献只算一次回合数。
374
+ const mid = f.scope.matchIds[0];
375
+ if (mid && !countedForRounds.has(mid)) {
376
+ countedForRounds.add(mid);
377
+ roundCount += f.scope.roundCount;
378
+ }
379
+ for (const m of f.scope.matchIds) matchIds.add(m);
380
+ }
381
+
382
+ return {
383
+ schemaVersion: first.schemaVersion,
384
+ computeVersion: first.computeVersion,
385
+ mapName: first.mapName,
386
+ calibrationVersion: first.calibrationVersion,
387
+ triAvailability: fields.some((f) => f.triAvailability === "none") ? "none" : "full",
388
+ scope: { kind: scope.kind, team: scope.team, economy, roundCount, matchIds: [...matchIds] },
389
+ grid: first.grid,
390
+ maxSec,
391
+ denomCt,
392
+ denomT,
393
+ fields: out,
394
+ };
395
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import JSZip from "jszip";
3
+ import type { PackagePlayer, PackageRound } from "@cs2dak/contract";
4
+ import { createPlayerResolver } from "./resolve.js";
5
+ import { loadDemoPackageFromZip } from "./loader.js";
6
+
7
+ const players: PackagePlayer[] = [
8
+ { steamId64: "76561198000000001", name: "Alpha", teamKey: "teamA" },
9
+ { steamId64: "76561198000000002", name: "Bravo", teamKey: "teamB" },
10
+ ];
11
+
12
+ const round = (roundNumber: number, teamASide: "t" | "ct"): PackageRound => ({
13
+ roundNumber,
14
+ startTick: roundNumber * 1000,
15
+ freezeEndTick: roundNumber * 1000 + 100,
16
+ endTick: roundNumber * 1000 + 900,
17
+ teamASide,
18
+ teamBSide: teamASide === "t" ? "ct" : "t",
19
+ teamAScoreBefore: 0,
20
+ teamBScoreBefore: 0,
21
+ teamAEconomy: "full",
22
+ teamBEconomy: "full",
23
+ winnerTeamKey: "teamA",
24
+ winnerSide: teamASide,
25
+ endReason: "t_win",
26
+ });
27
+
28
+ describe("createPlayerResolver", () => {
29
+ const resolver = createPlayerResolver(players, [round(1, "t"), round(13, "ct")]);
30
+
31
+ it("resolves playerIndex to player row", () => {
32
+ expect(resolver.byIndex(0).name).toBe("Alpha");
33
+ expect(resolver.byIndex(1).teamKey).toBe("teamB");
34
+ expect(() => resolver.byIndex(2)).toThrow(/out of range/);
35
+ });
36
+
37
+ it("byIndexOrNull passes through null", () => {
38
+ expect(resolver.byIndexOrNull(null)).toBeNull();
39
+ expect(resolver.byIndexOrNull(undefined)).toBeNull();
40
+ expect(resolver.byIndexOrNull(0)?.name).toBe("Alpha");
41
+ });
42
+
43
+ it("derives per-round side from teamKey + rounds", () => {
44
+ expect(resolver.sideOf(0, 1)).toBe("t");
45
+ expect(resolver.sideOf(1, 1)).toBe("ct");
46
+ expect(resolver.sideOf(0, 13)).toBe("ct");
47
+ expect(resolver.teamSideOf("teamB", 13)).toBe("t");
48
+ expect(() => resolver.sideOf(0, 99)).toThrow(/Unknown roundNumber/);
49
+ });
50
+
51
+ it("maps steamId64 back to playerIndex", () => {
52
+ expect(resolver.indexOfSteamId("76561198000000002")).toBe(1);
53
+ expect(resolver.indexOfSteamId("76561198999999999")).toBeNull();
54
+ });
55
+ });
56
+
57
+ describe("loadDemoPackageFromZip version gate", () => {
58
+ it("rejects v2 packages with a re-export hint", async () => {
59
+ const zip = new JSZip();
60
+ zip.file("manifest.json", JSON.stringify({ schemaVersion: "cs2-demo-format/2.3" }));
61
+ const bytes = await zip.generateAsync({ type: "uint8array" });
62
+ await expect(loadDemoPackageFromZip(bytes)).rejects.toThrow(/cs2df/);
63
+ });
64
+
65
+ it("rejects packages without manifest version", async () => {
66
+ const zip = new JSZip();
67
+ zip.file("manifest.json", JSON.stringify({}));
68
+ const bytes = await zip.generateAsync({ type: "uint8array" });
69
+ await expect(loadDemoPackageFromZip(bytes)).rejects.toThrow(/不支持的包版本/);
70
+ });
71
+
72
+ it("does not treat a missing v3 required source file as an empty event array", async () => {
73
+ const zip = new JSZip();
74
+ zip.file("manifest.json", JSON.stringify({
75
+ schemaVersion: "cs2-demo-format/3.0",
76
+ exporter: { name: "test", version: "0" },
77
+ parser: { name: "test", version: "0" },
78
+ demo: { hash: null, sourceFileName: null },
79
+ mapName: "de_mirage",
80
+ tickrate: 64,
81
+ exportedAt: "2026-01-01T00:00:00Z",
82
+ files: {
83
+ match: "match.json",
84
+ players: "players.json",
85
+ rounds: "rounds.json",
86
+ playerStats: "player-stats.json",
87
+ playerEconomies: "player-economies.json",
88
+ kills: "kills.json",
89
+ damages: "damages.json",
90
+ blinds: "blinds.json",
91
+ bombs: "bombs.json",
92
+ grenades: "grenades.json",
93
+ clutches: "clutches.json",
94
+ },
95
+ }));
96
+ for (const file of ["match.json", "players.json", "rounds.json", "player-economies.json"]) zip.file(file, "{}");
97
+ const bytes = await zip.generateAsync({ type: "uint8array" });
98
+ await expect(loadDemoPackageFromZip(bytes)).rejects.toThrow(/Missing player-stats\.json/);
99
+ });
100
+ });
package/src/resolve.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { DemoPackage, PackagePlayer, PackageRound, Side, TeamKey } from "@cs2dak/contract";
2
+
3
+ /**
4
+ * v3 基础设施:playerIndex → player 解析与 per-round side 推导。
5
+ * 所有下游模块统一经此层取 player / side,禁止散落的 steamId64 查找。
6
+ */
7
+ export interface PlayerResolver {
8
+ /** players.json 原始行序(playerIndex 的真相源) */
9
+ readonly players: readonly PackagePlayer[];
10
+ byIndex(index: number): PackagePlayer;
11
+ byIndexOrNull(index: number | null | undefined): PackagePlayer | null;
12
+ /** 同一局内 steamId64 → playerIndex(cohort 身份归并入口用) */
13
+ indexOfSteamId(steamId64: string): number | null;
14
+ /** playerIndex 对应的 steamId64;index 无效返回空串。 */
15
+ steamIdOf(index: number | null | undefined): string;
16
+ /** playerIndex 对应的玩家名;index 无效返回 null。 */
17
+ nameByIndex(index: number | null | undefined): string | null;
18
+ /** 玩家在指定回合所处 side(由 teamKey + rounds.teamASide/BSide 推导) */
19
+ sideOf(playerIndex: number, roundNumber: number): Side;
20
+ /** 指定回合某 teamKey 的 side */
21
+ teamSideOf(teamKey: TeamKey, roundNumber: number): Side;
22
+ }
23
+
24
+ export function createPlayerResolver(
25
+ players: readonly PackagePlayer[],
26
+ rounds: readonly PackageRound[]
27
+ ): PlayerResolver {
28
+ const steamIdToIndex = new Map<string, number>();
29
+ players.forEach((p, i) => {
30
+ if (!steamIdToIndex.has(p.steamId64)) steamIdToIndex.set(p.steamId64, i);
31
+ });
32
+ const roundByNumber = new Map<number, PackageRound>();
33
+ for (const r of rounds) roundByNumber.set(r.roundNumber, r);
34
+
35
+ const requireRound = (roundNumber: number): PackageRound => {
36
+ const round = roundByNumber.get(roundNumber);
37
+ if (!round) throw new Error(`Unknown roundNumber ${roundNumber}`);
38
+ return round;
39
+ };
40
+
41
+ const byIndex = (index: number): PackagePlayer => {
42
+ const player = players[index];
43
+ if (!player) throw new Error(`playerIndex ${index} out of range (players=${players.length})`);
44
+ return player;
45
+ };
46
+
47
+ const teamSideOf = (teamKey: TeamKey, roundNumber: number): Side => {
48
+ const round = requireRound(roundNumber);
49
+ return teamKey === "teamA" ? round.teamASide : round.teamBSide;
50
+ };
51
+
52
+ const byIndexOrNull = (index: number | null | undefined): PackagePlayer | null =>
53
+ index === null || index === undefined ? null : byIndex(index);
54
+
55
+ return {
56
+ players,
57
+ byIndex,
58
+ byIndexOrNull,
59
+ indexOfSteamId: (steamId64) => steamIdToIndex.get(steamId64) ?? null,
60
+ steamIdOf: (index) => byIndexOrNull(index)?.steamId64 ?? "",
61
+ nameByIndex: (index) => byIndexOrNull(index)?.name ?? null,
62
+ sideOf: (playerIndex, roundNumber) => teamSideOf(byIndex(playerIndex).teamKey, roundNumber),
63
+ teamSideOf,
64
+ };
65
+ }
66
+
67
+ export function createResolverFromPackage(pkg: DemoPackage): PlayerResolver {
68
+ return createPlayerResolver(pkg.players, pkg.rounds);
69
+ }