@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
package/src/qa.test.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, PackageDamage } from "@cs2dak/contract";
3
+ import { activeDamages } from "./utils.js";
4
+ import { buildQaReport, demoSourceAvailability } from "./qa.js";
5
+
6
+ function damage(tick: number): PackageDamage {
7
+ return {
8
+ roundNumber: 1,
9
+ tick,
10
+ attackerIndex: 0,
11
+ victimIndex: 1,
12
+ weapon: "ak47",
13
+ hitgroup: "head",
14
+ healthDamage: 12,
15
+ healthDamageRaw: 12,
16
+ armorDamage: 0,
17
+ victimHealthBefore: 100,
18
+ victimArmorAfter: 100,
19
+ attackerPosition: { x: 0, y: 0, z: 0 },
20
+ victimPosition: { x: 100, y: 0, z: 0 }
21
+ };
22
+ }
23
+
24
+ function pkg(overrides: Partial<DemoPackage>): DemoPackage {
25
+ return {
26
+ manifest: {
27
+ schemaVersion: "cs2-demo-format/3.0",
28
+ exporter: { name: "test", version: "0" },
29
+ parser: { name: "test", version: "0" },
30
+ demo: { hash: null, sourceFileName: null },
31
+ mapName: "de_mirage",
32
+ tickrate: 64,
33
+ exportedAt: "2026-01-01T00:00:00Z",
34
+ files: {
35
+ match: "match.json",
36
+ players: "players.json",
37
+ rounds: "rounds.json",
38
+ playerStats: "player-stats.json",
39
+ playerEconomies: "player-economies.json",
40
+ kills: "kills.json",
41
+ damages: "damages.json",
42
+ blinds: "blinds.json",
43
+ bombs: "bombs.json",
44
+ grenades: "grenades.json",
45
+ clutches: "clutches.json"
46
+ }
47
+ },
48
+ match: {
49
+ mapName: "de_mirage",
50
+ tickrate: 64,
51
+ durationSeconds: 120,
52
+ serverName: null,
53
+ source: "test",
54
+ teamA: { teamKey: "teamA", name: "A", score: 1 },
55
+ teamB: { teamKey: "teamB", name: "B", score: 0 }
56
+ },
57
+ players: [
58
+ { steamId64: "76561198000000001", name: "A", teamKey: "teamA" },
59
+ { steamId64: "76561198000000002", name: "B", teamKey: "teamB" }
60
+ ],
61
+ rounds: [{
62
+ roundNumber: 1,
63
+ startTick: 1,
64
+ freezeEndTick: 100,
65
+ endTick: 500,
66
+ winnerTeamKey: "teamA",
67
+ winnerSide: "T",
68
+ teamASide: "T",
69
+ teamBSide: "CT",
70
+ endReason: "target_bombed",
71
+ durationSeconds: 10
72
+ }],
73
+ playerEconomies: [],
74
+ playerStats: [],
75
+ kills: [],
76
+ damages: [],
77
+ blinds: [],
78
+ bombs: [],
79
+ grenades: [],
80
+ clutches: [],
81
+ ...overrides
82
+ } as DemoPackage;
83
+ }
84
+
85
+ describe("buildQaReport", () => {
86
+ it("reports v3 required event families as available when a valid match has zero events", () => {
87
+ const availability = demoSourceAvailability(pkg({ kills: [], damages: [], blinds: [], bombs: [], grenades: [], clutches: [] }));
88
+
89
+ expect(availability).toMatchObject({
90
+ damages: "available",
91
+ bombs: "available",
92
+ utility: "available",
93
+ clutches: "available",
94
+ richKills: "available",
95
+ });
96
+ });
97
+
98
+ it("accepts pre-freeze damage rows but excludes them from active damage analytics", () => {
99
+ const demo = pkg({ damages: [damage(50), damage(120)] });
100
+
101
+ expect(buildQaReport(demo).issues).not.toContainEqual(expect.objectContaining({
102
+ code: "damages.tick_outside_round"
103
+ }));
104
+ expect(activeDamages(demo).map((row) => row.tick)).toEqual([120]);
105
+ });
106
+
107
+ it("still reports damage rows before the round start", () => {
108
+ const demo = pkg({ damages: [damage(0)] });
109
+
110
+ expect(buildQaReport(demo).issues).toContainEqual(expect.objectContaining({
111
+ severity: "error",
112
+ code: "damages.tick_outside_round"
113
+ }));
114
+ });
115
+ });
package/src/qa.ts CHANGED
@@ -3,11 +3,26 @@ import { isNamedWeapon, normalizeWeapon, round } from "./utils.js";
3
3
 
4
4
  export type ScoreboardFieldAvailability = PlayerScoreboardRow["fieldAvailability"];
5
5
 
6
+ export type DemoSourceAvailability = ScoreboardFieldAvailability & {
7
+ utility: "available";
8
+ clutches: "available";
9
+ };
10
+
6
11
  export function buildQaReport(pkg: DemoPackage): QaReport {
7
12
  const issues: QaIssue[] = [];
8
13
  const roundNumbers = pkg.rounds.map((round) => round.roundNumber).sort((a, b) => a - b);
9
14
  const roundsByNumber = new Map(pkg.rounds.map((round) => [round.roundNumber, round]));
10
- const playerIds = new Set(pkg.players.map((player) => player.steamId64));
15
+ // 3.0.3+: 非最终回合的事件窗口延伸到下一回合 startTick 前
16
+ const sortedRounds = [...pkg.rounds].sort((a, b) => a.roundNumber - b.roundNumber);
17
+ const eventEndTickByRound = new Map<number, number>();
18
+ for (let i = 0; i < sortedRounds.length; i++) {
19
+ const current = sortedRounds[i];
20
+ const next = sortedRounds[i + 1];
21
+ eventEndTickByRound.set(
22
+ current.roundNumber,
23
+ next ? next.startTick - 1 : current.endTick
24
+ );
25
+ }
11
26
 
12
27
  for (let i = 0; i < roundNumbers.length; i += 1) {
13
28
  if (roundNumbers[i] !== i + 1) {
@@ -64,7 +79,8 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
64
79
  return;
65
80
  }
66
81
  const minTick = allowFreeze ? roundRow.startTick : roundRow.freezeEndTick;
67
- if (tick < minTick || tick > roundRow.endTick) {
82
+ const eventEnd = eventEndTickByRound.get(roundNumber) ?? roundRow.endTick;
83
+ if (tick < minTick || tick > eventEnd) {
68
84
  issues.push({
69
85
  severity: "error",
70
86
  code: `${kind}.tick_outside_round`,
@@ -86,25 +102,25 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
86
102
 
87
103
  pkg.kills.forEach((kill, index) => {
88
104
  checkEventTick("kills", kill.roundNumber, kill.tick, index);
89
- if (kill.killerSteamId64 && !playerIds.has(kill.killerSteamId64)) {
105
+ if (kill.killerIndex !== null && kill.killerIndex >= pkg.players.length) {
90
106
  issues.push({
91
107
  severity: "warning",
92
108
  code: "kill.unknown_killer",
93
- message: `Killer ${kill.killerSteamId64} is not present in players.json.`,
109
+ message: `Killer playerIndex ${kill.killerIndex} is out of range (players.length=${pkg.players.length}).`,
94
110
  path: "kills"
95
111
  });
96
112
  }
97
- if (!playerIds.has(kill.victimSteamId64)) {
113
+ if (kill.victimIndex >= pkg.players.length) {
98
114
  issues.push({
99
115
  severity: "error",
100
116
  code: "kill.unknown_victim",
101
- message: `Victim ${kill.victimSteamId64} is not present in players.json.`,
117
+ message: `Victim playerIndex ${kill.victimIndex} is out of range (players.length=${pkg.players.length}).`,
102
118
  path: "kills"
103
119
  });
104
120
  }
105
121
  });
106
122
 
107
- pkg.damages.forEach((damage, index) => checkEventTick("damages", damage.roundNumber, damage.tick, index));
123
+ pkg.damages.forEach((damage, index) => checkEventTick("damages", damage.roundNumber, damage.tick, index, true));
108
124
  pkg.blinds.forEach((blind, index) => checkEventTick("blinds", blind.roundNumber, blind.tick, index));
109
125
  pkg.grenades.forEach((grenade, index) => {
110
126
  checkEventTick("grenades", grenade.roundNumber, grenade.throwTick, index);
@@ -117,11 +133,11 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
117
133
  const events = bombEventsByRound.get(bomb.roundNumber) ?? [];
118
134
  events.push(bomb);
119
135
  bombEventsByRound.set(bomb.roundNumber, events);
120
- if (bomb.actorSteamId64 && !playerIds.has(bomb.actorSteamId64)) {
136
+ if (bomb.actorIndex !== null && bomb.actorIndex >= pkg.players.length) {
121
137
  issues.push({
122
138
  severity: "warning",
123
139
  code: "bomb.unknown_actor",
124
- message: `Bomb actor ${bomb.actorSteamId64} is not present in players.json.`,
140
+ message: `Bomb actor playerIndex ${bomb.actorIndex} is out of range (players.length=${pkg.players.length}).`,
125
141
  path: "bombs"
126
142
  });
127
143
  }
@@ -166,13 +182,32 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
166
182
  }
167
183
 
168
184
  export function fieldAvailability(pkg: DemoPackage): ScoreboardFieldAvailability {
185
+ const availability = demoSourceAvailability(pkg);
186
+ return {
187
+ playerStats: availability.playerStats,
188
+ economy: availability.economy,
189
+ rounds: availability.rounds,
190
+ richKills: availability.richKills,
191
+ damages: availability.damages,
192
+ bombs: availability.bombs,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Source capability is determined by the validated v3 contract, never by
198
+ * whether a particular match happened to produce an event. The loader rejects
199
+ * absent required files before this function can run.
200
+ */
201
+ export function demoSourceAvailability(pkg: DemoPackage): DemoSourceAvailability {
169
202
  return {
170
- playerStats: pkg.playerStats.length > 0 ? "available" : "missing",
171
- economy: pkg.playerEconomies.length > 0 ? "available" : "missing",
172
- rounds: pkg.rounds.length > 0 ? "available" : "missing",
203
+ playerStats: "available",
204
+ economy: "available",
205
+ rounds: "available",
173
206
  richKills: richKillAvailability(pkg),
174
- damages: pkg.damages.length > 0 ? "available" : "missing",
175
- bombs: pkg.bombs.length > 0 ? "available" : "missing"
207
+ damages: "available",
208
+ bombs: "available",
209
+ utility: "available",
210
+ clutches: "available",
176
211
  };
177
212
  }
178
213
 
@@ -189,7 +224,8 @@ export function fieldConfidence(availability: ScoreboardFieldAvailability): numb
189
224
  }
190
225
 
191
226
  function richKillAvailability(pkg: DemoPackage): ScoreboardFieldAvailability["richKills"] {
192
- if (pkg.kills.length === 0) return "missing";
227
+ // A legal empty kills.json is an observed zero, not an unavailable source.
228
+ if (pkg.kills.length === 0) return "available";
193
229
  const hasFlags = pkg.kills.some((kill) => "throughSmoke" in kill && "noScope" in kill && "penetratedObjects" in kill);
194
230
  const activeWeaponsAreNames = pkg.kills.some((kill) => kill.killerActiveWeapon && isNamedWeapon(normalizeWeapon(kill.killerActiveWeapon)));
195
231
  if (hasFlags && activeWeaponsAreNames) return "available";
@@ -0,0 +1,79 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+ import { beforeAll, describe, expect, it } from "vitest";
5
+ import { buildRadarFieldGrid } from "@cs2dak/maps";
6
+ import { RADAR_FIELD_BASES, type DemoPackage } from "@cs2dak/contract";
7
+ import { loadDemoPackageFromZip } from "./loader.js";
8
+ import { buildMatchRadarField, aggregateRadarFields } from "./radar-field.js";
9
+
10
+ let pkg: DemoPackage;
11
+
12
+ beforeAll(async () => {
13
+ const zip = await readFile(
14
+ fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
15
+ );
16
+ pkg = await loadDemoPackageFromZip(zip);
17
+ });
18
+
19
+ function radarFieldDigest(fields: ReturnType<typeof buildMatchRadarField>): string {
20
+ const hash = createHash("sha256");
21
+ for (const field of fields) {
22
+ hash.update(field.scope.team ?? "");
23
+ hash.update(Buffer.from(field.denomCt.buffer, field.denomCt.byteOffset, field.denomCt.byteLength));
24
+ hash.update(Buffer.from(field.denomT.buffer, field.denomT.byteOffset, field.denomT.byteLength));
25
+ for (const base of RADAR_FIELD_BASES) {
26
+ for (const row of field.fields[base]) {
27
+ hash.update(Buffer.from(row.buffer, row.byteOffset, row.byteLength));
28
+ }
29
+ }
30
+ }
31
+ return hash.digest("hex");
32
+ }
33
+
34
+ describe("buildMatchRadarField", () => {
35
+ it("每场产出两份按队归属的加性场贡献", () => {
36
+ const grid = buildRadarFieldGrid("de_ancient");
37
+ expect(grid).not.toBeNull();
38
+ const fields = buildMatchRadarField(pkg, { matchId: "m", grid: grid!, bvh: null });
39
+ expect(fields).toHaveLength(2);
40
+
41
+ for (const f of fields) {
42
+ expect(f.scope.kind).toBe("team");
43
+ expect(f.triAvailability).toBe("none"); // 无 .tri
44
+ expect(f.denomCt).toHaveLength(f.maxSec);
45
+ expect(f.denomT).toHaveLength(f.maxSec);
46
+ expect(f.fields.ctVis).toHaveLength(f.maxSec);
47
+ expect(f.fields.ctAim).toHaveLength(f.maxSec);
48
+ expect(f.fields.ctSound).toHaveLength(f.maxSec);
49
+ expect(f.fields.ctVis[0]!).toHaveLength(f.grid.cells.length);
50
+ }
51
+ // 至少有一个回合被采样(denom 有非零),且有视野/位置计数。
52
+ const totalDenom = fields.reduce((s, f) => s + f.denomCt.reduce((a, b) => a + b, 0), 0);
53
+ expect(totalDenom).toBeGreaterThan(0);
54
+ const totalVis = fields.reduce((s, f) => s + f.fields.tPres.reduce((a, row) => a + row.reduce((x, y) => x + y, 0), 0), 0);
55
+ expect(totalVis).toBeGreaterThan(0);
56
+ const totalSound = fields.reduce((s, f) => s + f.fields.tSound.reduce((a, row) => a + row.reduce((x, y) => x + y, 0), 0), 0);
57
+ expect(totalSound).toBeGreaterThan(0);
58
+ });
59
+
60
+ it("sample 输出指纹保持稳定", () => {
61
+ const grid = buildRadarFieldGrid("de_ancient")!;
62
+ const fields = buildMatchRadarField(pkg, { matchId: "snapshot", grid, bvh: null });
63
+ expect(radarFieldDigest(fields)).toBe("0405c90078d54c7ce4a391a1140a9cf2dc6bd3c9a28b4aace3be5d5be3bc8319");
64
+ });
65
+
66
+ it("aggregateRadarFields 是逐元素加法(联赛基线 = 各贡献之和)", () => {
67
+ const grid = buildRadarFieldGrid("de_ancient")!;
68
+ const fields = buildMatchRadarField(pkg, { matchId: "m", grid, bvh: null });
69
+ const league = aggregateRadarFields(fields, { kind: "league", team: null })!;
70
+ expect(league.scope.kind).toBe("league");
71
+
72
+ // 抽查某秒某格:聚合 = 两份之和。
73
+ const s = 20;
74
+ const g = Math.floor(grid.cells.length / 2);
75
+ const expected = fields[0]!.fields.ctVis[s]![g]! + fields[1]!.fields.ctVis[s]![g]!;
76
+ expect(league.fields.ctVis[s]![g]!).toBe(expected);
77
+ expect(league.denomCt[s]!).toBe(fields[0]!.denomCt[s]! + fields[1]!.denomCt[s]!);
78
+ });
79
+ });