@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/src/index.test.ts CHANGED
@@ -1,105 +1,71 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { fileURLToPath } from "node:url";
4
+ import type { DemoPackage } from "@cs2dak/contract";
4
5
  import {
5
6
  analyzeDemoPackage,
6
- buildMatchWorkspaceModel,
7
- buildDemoViewModel,
8
7
  computeAccountRatingsV2,
9
- deriveAccountSignalsV2,
8
+ deriveRRSignals,
9
+ derivePlayerWeaponHighlights,
10
10
  deriveRRIndicators,
11
11
  loadDemoPackageFromZip
12
12
  } from "./index";
13
13
 
14
- describe("analyzeDemoPackage", () => {
15
- it("builds a match workspace model with replay and user-facing modules from a strict v2 export", async () => {
16
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
17
- const pkg = await loadDemoPackageFromZip(zip);
18
- const workspace = buildMatchWorkspaceModel(pkg);
19
-
20
- expect(workspace.version).toBe("cs2-demo-analysis-kit/workspace-0.1");
21
- expect(workspace.title).toBe("Team A vs Team B");
22
- expect(workspace.tabs.map((tab) => tab.key)).toEqual(["overview", "rounds", "players", "economy", "map", "replay"]);
23
- expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("qa");
24
- expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("economySwing");
25
- expect(workspace.rounds).toHaveLength(21);
26
- expect(workspace.rounds[0]?.events.some((event) => event.type === "kill")).toBe(true);
27
- expect(workspace.players).toHaveLength(10);
28
- expect(workspace.players[0]?.rrBreakdown.length).toBe(5);
29
- expect(workspace.map.modes.map((mode) => mode.key)).toEqual(["death", "kill", "grenade", "bomb", "position"]);
30
- expect(workspace.replay.available).toBe(true);
31
- expect(workspace.replay.rounds.length).toBeGreaterThan(0);
32
- expect(workspace.replay.rounds[0]?.players[0]?.frames.length).toBeGreaterThan(0);
33
- expect(workspace.replay.capabilities.hasDefuseKit).toBe(true);
34
- expect(workspace.replay.capabilities.hasBombPosition).toBe(false);
35
- expect(workspace.replay.rounds.some((round) => round.players.some((player) => player.frames.some((frame) => frame.weapon && /^\d+$/.test(frame.weapon))))).toBe(false);
36
- expect(workspace.overview.story.length).toBeGreaterThan(0);
37
- // 开场 beat 必出:含地图显示名 + 比分,且用胜负动词之一描述结果。
38
- expect(workspace.overview.story[0]).toContain("Ancient");
39
- expect(workspace.overview.story[0]).toContain("13:8");
40
- expect(workspace.overview.story[0]).toMatch(/碾压|险胜|拿下/);
41
- expect(workspace.overview.story.join("\n")).not.toContain("打成");
42
- expect(workspace.adminQa.summary.errorCount).toBe(0);
43
- });
14
+ let pkg: DemoPackage;
44
15
 
45
- it("builds reusable analysis and view-model artifacts from a strict v2 export", async () => {
46
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
47
- const pkg = await loadDemoPackageFromZip(zip);
48
- const bundle = analyzeDemoPackage(pkg);
49
- const viewModel = buildDemoViewModel(bundle);
16
+ beforeAll(async () => {
17
+ const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)));
18
+ pkg = await loadDemoPackageFromZip(zip);
19
+ }, 30_000);
50
20
 
51
- expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
52
- expect(bundle.version).toBe("cs2-demo-analysis-kit/0.2");
21
+ describe("analyzeDemoPackage", () => {
22
+ it("builds reusable analysis and view-model artifacts from a v3 export", () => {
23
+ const bundle = analyzeDemoPackage(pkg);
24
+ expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/3.0");
25
+ expect(bundle.version).toBe("cs2-demo-analysis-kit/1.0");
26
+ expect(bundle.provenance.analysisVersion).toBe("cs2-demo-analysis-kit/1.0.1");
27
+ expect(bundle.provenance.sourceSchemaVersion).toBe("cs2-demo-format/3.0");
28
+ expect(bundle.provenance.ratingVersions.rr).toBeTruthy();
53
29
  expect(bundle.scoreboard).toHaveLength(10);
54
30
  expect(bundle.scoreboard[0]?.rr).toBeGreaterThan(0);
55
- expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(21);
56
- expect(bundle.playerRoundFacts).toHaveLength(210);
57
- expect(bundle.economy).toHaveLength(21);
58
- expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(142);
59
- expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(94);
60
- expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(142);
31
+ expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(23);
32
+ expect(bundle.playerRoundFacts).toHaveLength(230);
33
+ expect(bundle.economy).toHaveLength(23);
34
+ expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(153);
35
+ expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(89);
36
+ expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(153);
61
37
  expect(bundle.timeline.some((event) => event.type === "kill")).toBe(true);
62
38
  expect(bundle.heatmap.some((point) => point.kind === "death")).toBe(true);
63
39
  expect(bundle.timeline.find((event) => event.type === "round-end")?.clockPhase).toBe("round-end");
64
40
  expect(bundle.timeline.find((event) => event.type === "kill")?.clockLabel).toMatch(/^\d:\d{2}$/);
65
- expect(viewModel.scoreline).toBe("13:8");
66
- expect(viewModel.map.name).toBe("de_ancient");
67
- expect(viewModel.map.radarImageUrl).toBe("./maps/radars/de_ancient.png");
68
- expect(viewModel.qa.summary.errorCount).toBe(0);
69
41
  });
70
42
 
71
- it("derives value-account signals and computes v2 RR from the strict v2 fixture", async () => {
72
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
73
- const pkg = await loadDemoPackageFromZip(zip);
74
- const signals = deriveAccountSignalsV2(pkg);
43
+ it("derives RR six-account signals and computes six-account RR from the v3 fixture", () => {
44
+ const signals = deriveRRSignals(pkg);
75
45
  const ratings = computeAccountRatingsV2(pkg);
76
46
 
77
47
  expect(signals).toHaveLength(10);
78
48
  expect(ratings).toHaveLength(10);
79
- expect(signals[0]?.rounds).toBe(21);
80
- expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 0, even: 2, advantage: 5 });
81
- expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 1, even: 5, manUp: 1 });
82
- expect(signals[0]?.trade.tradedOpeningDeaths).toBe(1);
83
- expect(ratings[0]?.rr.model).toBe("value-accounts-v2-lite");
49
+ expect(signals[0]?.rounds).toBe(23);
50
+ // fixture(Team Liquid vs FlyQuest, de_anubis, 15 回合)的 combat 分布
51
+ expect(signals[0]?.trade.tradedOpeningDeaths).toBe(0);
52
+ expect(ratings[0]?.rr.model).toBe("rr-six-accounts");
84
53
  expect(ratings[0]?.rr.rr).toBeGreaterThan(0);
85
54
  });
86
55
 
87
- it("anchors accountRR so the per-match league mean is ~1.0", async () => {
88
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
89
- const pkg = await loadDemoPackageFromZip(zip);
56
+ it("scores accountRR against the frozen pro baseline instead of the match mean", () => {
90
57
  const bundle = analyzeDemoPackage(pkg);
91
58
 
92
59
  const accountRRs = bundle.scoreboard.map((row) => row.accountRR);
93
60
  const mean = accountRRs.reduce((sum, v) => sum + v, 0) / accountRRs.length;
94
- expect(mean).toBeCloseTo(1.0, 2);
95
- // 锚定后必然有人高于、有人低于 1.0
61
+ expect(mean).toBeGreaterThan(0);
62
+ expect(mean).not.toBeCloseTo(1.0, 2);
63
+ // 职业基线不会强制当前比赛均值为 1.0,但仍应保留个体区分。
96
64
  expect(accountRRs.some((v) => v > 1.0)).toBe(true);
97
65
  expect(accountRRs.some((v) => v < 1.0)).toBe(true);
98
66
  });
99
67
 
100
- it("exposes RRIndicators without rebuilding them in cohort callers", async () => {
101
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
102
- const pkg = await loadDemoPackageFromZip(zip);
68
+ it("exposes RRIndicators without rebuilding them in cohort callers", () => {
103
69
  const indicators = deriveRRIndicators(pkg);
104
70
  const bundle = analyzeDemoPackage(pkg);
105
71
 
@@ -107,9 +73,7 @@ describe("analyzeDemoPackage", () => {
107
73
  expect(indicators[0]).toEqual(bundle.playerIndicators[0]?.indicators);
108
74
  });
109
75
 
110
- it("wires player-stats truth into RRIndicators instead of legacy approximations", async () => {
111
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
112
- const pkg = await loadDemoPackageFromZip(zip);
76
+ it("wires player-stats truth into RRIndicators instead of legacy approximations", () => {
113
77
  const statsTruth = pkg.playerStats[0]!;
114
78
  const patchedStats = {
115
79
  ...statsTruth,
@@ -119,14 +83,16 @@ describe("analyzeDemoPackage", () => {
119
83
  };
120
84
  const patchedPkg = {
121
85
  ...pkg,
122
- playerStats: pkg.playerStats.map((row) => row.steamId64 === statsTruth.steamId64 ? patchedStats : row)
86
+ playerStats: pkg.playerStats.map((row) => row.playerIndex === statsTruth.playerIndex ? patchedStats : row)
123
87
  };
124
88
  const indicators = deriveRRIndicators(patchedPkg);
125
89
  const bombDeathStats = patchedStats;
126
90
  const wallbangStats = pkg.playerStats.find((row) => row.wallbangKillCount > 0)!;
127
91
 
128
- const bombDeathIndicators = indicators.find((row) => row.steamId64 === bombDeathStats.steamId64)!;
129
- const wallbangIndicators = indicators.find((row) => row.steamId64 === wallbangStats.steamId64)!;
92
+ const bombDeathSteamId64 = pkg.players[statsTruth.playerIndex]?.steamId64 ?? "unknown";
93
+ const wallbangSteamId64 = pkg.players[wallbangStats.playerIndex]?.steamId64 ?? "unknown";
94
+ const bombDeathIndicators = indicators.find((row) => row.steamId64 === bombDeathSteamId64)!;
95
+ const wallbangIndicators = indicators.find((row) => row.steamId64 === wallbangSteamId64)!;
130
96
 
131
97
  expect(bombDeathStats.deaths).not.toBe(bombDeathStats.combatDeathCount);
132
98
  expect(bombDeathIndicators.combatDeathCount).toBe(bombDeathStats.combatDeathCount);
@@ -134,9 +100,7 @@ describe("analyzeDemoPackage", () => {
134
100
  expect(wallbangIndicators.wallbangKillCount).toBe(wallbangStats.wallbangKillCount);
135
101
  });
136
102
 
137
- it("surfaces account breakdown and context status on the scoreboard", async () => {
138
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
139
- const pkg = await loadDemoPackageFromZip(zip);
103
+ it("surfaces account breakdown and context status on the scoreboard", () => {
140
104
  const bundle = analyzeDemoPackage(pkg);
141
105
  const row = bundle.scoreboard[0]!;
142
106
 
@@ -146,17 +110,15 @@ describe("analyzeDemoPackage", () => {
146
110
  expect(row.accountContextStatus.manState).toBe("available");
147
111
  });
148
112
 
149
- it("surfaces rich v2 fields and confidence on the scoreboard", async () => {
150
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
151
- const pkg = await loadDemoPackageFromZip(zip);
113
+ it("surfaces rich v2 fields and confidence on the scoreboard", () => {
152
114
  const bundle = analyzeDemoPackage(pkg);
153
115
  const row = bundle.scoreboard[0]!;
154
116
 
155
- expect(row.combatDeathCount).toBe(11);
117
+ expect(row.combatDeathCount).toBe(13);
156
118
  expect(row.bombDeathCount).toBe(0);
157
- expect(row.bombPlantCount).toBe(4);
119
+ expect(row.bombPlantCount).toBeGreaterThanOrEqual(0);
158
120
  expect(row.noScopeKillCount).toBe(0);
159
- expect(row.throughSmokeKillCount).toBe(2);
121
+ expect(row.throughSmokeKillCount).toBe(1);
160
122
  expect(row.fieldAvailability).toEqual({
161
123
  playerStats: "available",
162
124
  economy: "available",
@@ -168,17 +130,35 @@ describe("analyzeDemoPackage", () => {
168
130
  expect(row.confidence).toBeCloseTo(0.917, 3);
169
131
  });
170
132
 
171
- it("emits null context buckets (not zero) when the data source is missing", async () => {
172
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
173
- const pkg = await loadDemoPackageFromZip(zip);
133
+ it("derives reusable per-player weapon kill distribution and highlight facts", () => {
134
+ const facts = derivePlayerWeaponHighlights(pkg);
135
+ const bundle = analyzeDemoPackage(pkg);
136
+
137
+ expect(facts).toHaveLength(pkg.players.length);
138
+ expect(bundle.playerWeaponHighlights).toEqual(facts);
139
+ expect(facts.every((row) => row.weapons.reduce((sum, weapon) => sum + weapon.kills, 0) === row.totalKills)).toBe(true);
140
+ expect(facts.flatMap((row) => row.weapons).some((weapon) => weapon.weapon === "ak47")).toBe(true);
141
+ expect(facts.flatMap((row) => row.weapons).every((weapon) =>
142
+ weapon.headshotKills <= weapon.kills
143
+ && weapon.tradeKills <= weapon.kills
144
+ && weapon.noScopeKills <= weapon.kills
145
+ && weapon.throughSmokeKills <= weapon.kills
146
+ && weapon.wallbangKills <= weapon.kills
147
+ )).toBe(true);
148
+ expect(facts.every((row) => row.highlights.wallbangKills != null)).toBe(true);
149
+ expect(facts.every((row) => row.highlights.noScopeKills != null)).toBe(true);
150
+ expect(facts.every((row) => row.highlights.throughSmokeKills != null)).toBe(true);
151
+ });
174
152
 
153
+ it("emits null context buckets (not zero) when the data source is missing", () => {
175
154
  // 剥离经济源 → buyDelta 降级为 null(而非零桶);manState 仍可用
176
- const noEconomy = deriveAccountSignalsV2({ ...pkg, playerEconomies: [] });
155
+ const noEconomy = deriveRRSignals({ ...pkg, playerEconomies: [] });
177
156
  expect(noEconomy[0]?.combat.killsByBuyDelta).toBeNull();
178
157
  expect(noEconomy[0]?.combat.killsByManState).not.toBeNull();
179
158
 
180
159
  // 剥离回合源 → manState 降级为 null
181
- const noRounds = deriveAccountSignalsV2({ ...pkg, rounds: [] });
160
+ const noRounds = deriveRRSignals({ ...pkg, rounds: [] });
182
161
  expect(noRounds[0]?.combat.killsByManState).toBeNull();
183
162
  });
163
+
184
164
  });
package/src/index.ts CHANGED
@@ -4,19 +4,36 @@ import { buildQaReport } from "./qa.js";
4
4
  import { buildPlayerRoundFacts, buildPlayerIndicators, buildScoreboard } from "./scoreboard.js";
5
5
  import { computeAccountRatingsV2 } from "./signals.js";
6
6
  import { buildTimeline, buildEconomy, buildHeatmap } from "./timeline.js";
7
+ import { buildPlayerWeaponHighlights } from "./weapon-highlights.js";
7
8
 
8
9
  export { loadDemoPackageFromZip } from "./loader.js";
9
10
  export { normalizeDemoPackage } from "./normalize.js";
10
- export { deriveAccountSignalsV2, computeAccountRatingsV2 } from "./signals.js";
11
+ export { createPlayerResolver, createResolverFromPackage } from "./resolve.js";
12
+ export type { PlayerResolver } from "./resolve.js";
13
+ export { demoSourceAvailability, type DemoSourceAvailability } from "./qa.js";
14
+ export { deriveRRSignals, computeAccountRatingsV2 } from "./signals.js";
15
+ export { activeDamages, groupBy } from "./utils.js";
16
+ export * from "./spatial/index.js";
11
17
  export { deriveRRIndicators } from "./scoreboard.js";
12
- export { buildDemoViewModel, buildMatchWorkspaceModel } from "./workspace.js";
13
- export { displayWeaponName } from "./weapons.js";
14
- export { buildEconomyConversion, economyLabelCn } from "./economy.js";
18
+ export { buildPlayerRoundFacts } from "./scoreboard.js";
19
+ export { buildPlayerRoundUtilityFacts, type PlayerRoundUtilityFact } from "./utility-facts.js";
20
+ export { derivePlayerWeaponHighlights } from "./weapon-highlights.js";
21
+ export { buildTeamSideWinRates } from "./side-win-rate.js";
22
+ export { buildDuelsSignals, deriveDuels, deriveOpeningDuels } from "./duels.js";
23
+ export { buildMechanicsSignals, counterStrafeThresholdForWeapon, derivePlayerMechanics } from "./mechanics.js";
24
+ export { buildMatchRadarField, aggregateRadarFields, RADAR_FIELD_VERSION } from "./radar-field.js";
25
+ export type { BuildMatchRadarFieldOptions } from "./radar-field.js";
26
+ export type { SideWinRateStats, TeamSideWinRates } from "./side-win-rate.js";
27
+ export type { DuelClassification, DuelHpBucket, DuelRecord, DuelSignals, TtkDistribution } from "./duels.js";
15
28
  export type {
16
- EconomyTypeStats,
17
- EconomyConversion,
18
- MatchEconomyConversion,
19
- } from "./economy.js";
29
+ BurstLengthBuckets,
30
+ MechanicsMetricSet,
31
+ MechanicsSignals,
32
+ MedianSample,
33
+ PlayerMechanicsFact,
34
+ PreaimSample,
35
+ RateSample,
36
+ } from "./mechanics.js";
20
37
 
21
38
  export function analyzeDemoPackage(input: unknown): AnalysisBundle {
22
39
  const pkg = normalizeDemoPackage(input);
@@ -25,13 +42,25 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
25
42
  const playerIndicators = buildPlayerIndicators(pkg, playerRoundFacts);
26
43
  const accountRatings = computeAccountRatingsV2(pkg);
27
44
  const scoreboard = buildScoreboard(pkg, playerIndicators, accountRatings);
45
+ const playerWeaponHighlights = buildPlayerWeaponHighlights(pkg);
28
46
  const timeline = buildTimeline(pkg);
29
47
  const economy = buildEconomy(pkg);
30
48
  const heatmap = buildHeatmap(pkg);
31
49
 
32
50
  return analysisBundleSchema.parse({
33
- version: "cs2-demo-analysis-kit/0.2",
51
+ version: "cs2-demo-analysis-kit/1.0",
34
52
  sourceSchemaVersion: pkg.manifest.schemaVersion,
53
+ provenance: {
54
+ analysisVersion: "cs2-demo-analysis-kit/1.0.1",
55
+ sourceSchemaVersion: pkg.manifest.schemaVersion,
56
+ sourceDemoHash: pkg.manifest.demo?.hash ?? null,
57
+ exporter: pkg.manifest.exporter,
58
+ parser: pkg.manifest.parser,
59
+ ratingVersions: {
60
+ rr: playerIndicators[0]?.rr.weightsVersion ?? null,
61
+ valueAccounts: accountRatings[0]?.rr.weightsVersion ?? null
62
+ }
63
+ },
35
64
  mapName: pkg.match.mapName,
36
65
  tickrate: pkg.match.tickrate,
37
66
  teams: {
@@ -39,6 +68,7 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
39
68
  teamB: { name: pkg.match.teamB.name ?? "Team B", score: pkg.match.teamB.score }
40
69
  },
41
70
  scoreboard,
71
+ playerWeaponHighlights,
42
72
  playerIndicators,
43
73
  playerRoundFacts,
44
74
  timeline,
@@ -47,3 +77,5 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
47
77
  qa
48
78
  });
49
79
  }
80
+ export * from "./tactics/index.js";
81
+ export * from "./map-intelligence/index.js";
package/src/loader.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import JSZip from "jszip";
2
2
  import type { DemoPackage } from "@cs2dak/contract";
3
+ import { manifestSchema } from "@cs2dak/contract";
3
4
  import { normalizeDemoPackage, parsePackageJson } from "./normalize.js";
4
5
 
5
6
  export async function loadDemoPackageFromZip(bytes: ArrayBuffer | Uint8Array): Promise<DemoPackage> {
@@ -12,21 +13,34 @@ export async function loadDemoPackageFromZip(bytes: ArrayBuffer | Uint8Array): P
12
13
  return parsePackageJson(await file.async("string")) as T;
13
14
  };
14
15
 
15
- const manifest = await readJson<unknown>("manifest.json");
16
- const match = await readJson<unknown>("match.json");
17
- const players = await readJson<unknown>("players.json");
18
- const rounds = await readJson<unknown>("rounds.json");
19
- const playerEconomies = await readJson<unknown>("player-economies.json").catch(() => []);
20
- const playerStats = await readJson<unknown>("player-stats.json").catch(() => []);
21
- const kills = await readJson<unknown>("kills.json").catch(() => []);
22
- const damages = await readJson<unknown>("damages.json").catch(() => []);
23
- const blinds = await readJson<unknown>("blinds.json").catch(() => []);
24
- const bombs = await readJson<unknown>("bombs.json").catch(() => []);
25
- const grenades = await readJson<unknown>("grenades.json").catch(() => []);
26
- const clutches = await readJson<unknown>("clutches.json").catch(() => []);
27
- const shots = await readJson<unknown>("shots.json").catch(() => undefined);
28
- const positions1s = await readJson<unknown>("positions-1s.json").catch(() => undefined);
29
- const replay = await readJson<unknown>("replay.json").catch(() => undefined);
16
+ const rawManifest = await readJson<{ schemaVersion?: string }>("manifest.json");
17
+ const version = rawManifest?.schemaVersion ?? "unknown";
18
+ if (!version.startsWith("cs2-demo-format/3.")) {
19
+ throw new Error(
20
+ `不支持的包版本 ${version}:本版本只读取 cs2-demo-format/3.x,请用 cs2df 重新导出该 demo`
21
+ );
22
+ }
23
+ const manifest = manifestSchema.parse(rawManifest);
24
+ const files = manifest.files;
25
+
26
+ const optional = async (name: string | undefined): Promise<unknown> =>
27
+ name ? readJson<unknown>(name).catch(() => undefined) : undefined;
28
+ const match = await readJson<unknown>(files.match);
29
+ const players = await readJson<unknown>(files.players);
30
+ const rounds = await readJson<unknown>(files.rounds);
31
+ // cs2-demo-format/3.x requires these files. An empty, valid array means no
32
+ // event occurred; a missing or invalid file is a malformed package, not [] .
33
+ const playerEconomies = await readJson<unknown>(files.playerEconomies);
34
+ const playerStats = await readJson<unknown>(files.playerStats);
35
+ const kills = await readJson<unknown>(files.kills);
36
+ const damages = await readJson<unknown>(files.damages);
37
+ const blinds = await readJson<unknown>(files.blinds);
38
+ const bombs = await readJson<unknown>(files.bombs);
39
+ const grenades = await readJson<unknown>(files.grenades);
40
+ const clutches = await readJson<unknown>(files.clutches);
41
+ const shots = await optional(files.shots);
42
+ const replay = await optional(files.replay);
43
+ const duels = await optional(files.duels);
30
44
 
31
45
  return normalizeDemoPackage({
32
46
  manifest,
@@ -42,7 +56,7 @@ export async function loadDemoPackageFromZip(bytes: ArrayBuffer | Uint8Array): P
42
56
  grenades,
43
57
  clutches,
44
58
  shots,
45
- positions1s,
46
- replay
59
+ replay,
60
+ duels
47
61
  });
48
62
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FLAG_ALIVE, type DemoPackage } from "@cs2dak/contract";
3
+ import { extractAwpRoundFacts } from "./awp.js";
4
+ import { extractTeamAwpRoundFacts } from "./team-awp-round.js";
5
+ import type { ReplayRoundContext, ReplayRoundTrack } from "../tactics/replay-round-context.js";
6
+
7
+ describe("extractAwpRoundFacts", () => {
8
+ it("stops active AWP time after death and ignores invalid trajectory frames", () => {
9
+ const track: ReplayRoundTrack = {
10
+ playerIndex: 0, teamKey: "teamA", side: "ct", steamId64: "76561198000000001",
11
+ x: [1, 1, 1, Number.NaN], y: [1, 1, 1, 1], z: [1, 1, 1, 1],
12
+ flags: [FLAG_ALIVE, FLAG_ALIVE, 0, FLAG_ALIVE], place: [0, 0, 0, 0], weapon: [0, 0, 0, 0],
13
+ };
14
+ const round = { roundNumber: 1, freezeEndTick: 0, endTick: 128 } as DemoPackage["rounds"][number];
15
+ const context: ReplayRoundContext = { round, startTick: 0, tickStep: 32, frameCount: 4, placeDict: [], weaponDict: ["awp"], tracks: [track] };
16
+ const pkg = {
17
+ match: { tickrate: 64 }, playerEconomies: [], shots: undefined, kills: [],
18
+ } as unknown as DemoPackage;
19
+ expect(extractAwpRoundFacts(pkg, context, track).activeAwpSeconds).toBe(1);
20
+ });
21
+
22
+ it("does not count invalid-coordinate teammates toward double-AWP active time", () => {
23
+ const tracks: ReplayRoundTrack[] = [0, 1].map((playerIndex) => ({
24
+ playerIndex, teamKey: "teamA", side: "ct", steamId64: `7656119800000000${playerIndex + 1}`,
25
+ x: playerIndex === 0 ? [1, 1] : [Number.NaN, Number.NaN], y: [1, 1], z: [1, 1],
26
+ flags: [FLAG_ALIVE, FLAG_ALIVE], place: [0, 0], weapon: [0, 0],
27
+ }));
28
+ const round = { roundNumber: 1, freezeEndTick: 0, endTick: 64, teamASide: "ct", teamBSide: "t", teamAEconomy: "full", teamBEconomy: "full", winnerTeamKey: "teamA" } as DemoPackage["rounds"][number];
29
+ const context: ReplayRoundContext = { round, startTick: 0, tickStep: 32, frameCount: 2, placeDict: [], weaponDict: ["awp"], tracks };
30
+ const pkg = { match: { tickrate: 64, mapName: "de_nuke" }, rounds: [round], players: [{ teamKey: "teamA" }, { teamKey: "teamA" }], kills: [] } as unknown as DemoPackage;
31
+ const rows = tracks.map((track) => ({ teamKey: "teamA", playerIndex: track.playerIndex, freezeAwpOwnership: true, activeAwpSeconds: 1, awpShots: 0, awpKills: 0, availability: { replay: "available", nav: "missing", callouts: "missing", shots: "missing" } })) as unknown as import("@cs2dak/contract").PlayerPositionRoundFact[];
32
+ expect(extractTeamAwpRoundFacts(pkg, "m1", context, rows).find((item) => item.teamKey === "teamA")?.doubleAwpActiveSeconds).toBe(0);
33
+ });
34
+
35
+ it("materializes effective enemy-only AWP damage and retains zero for a damage-complete round", () => {
36
+ const round = { roundNumber: 1, freezeEndTick: 10, endTick: 100, teamASide: "ct", teamBSide: "t", teamAEconomy: "full", teamBEconomy: "full", winnerTeamKey: "teamA" } as DemoPackage["rounds"][number];
37
+ const tracks: ReplayRoundTrack[] = [
38
+ { playerIndex: 0, teamKey: "teamA", side: "ct", steamId64: "a", x: [1], y: [1], z: [1], flags: [FLAG_ALIVE], place: [0], weapon: [0] },
39
+ { playerIndex: 1, teamKey: "teamB", side: "t", steamId64: "b", x: [1], y: [1], z: [1], flags: [FLAG_ALIVE], place: [0], weapon: [0] },
40
+ ];
41
+ const context: ReplayRoundContext = { round, startTick: 10, tickStep: 32, frameCount: 1, placeDict: [], weaponDict: ["awp"], tracks };
42
+ const pkg = {
43
+ manifest: { files: { damages: "damages.json" } }, match: { tickrate: 64, mapName: "de_nuke" }, rounds: [round], players: [{ teamKey: "teamA" }, { teamKey: "teamB" }], kills: [],
44
+ damages: [
45
+ { roundNumber: 1, tick: 20, attackerIndex: 0, victimIndex: 1, weapon: "weapon_awp", healthDamage: 40, healthDamageRaw: 100 },
46
+ { roundNumber: 1, tick: 20, attackerIndex: 0, victimIndex: 0, weapon: "awp", healthDamage: 80, healthDamageRaw: 80 },
47
+ { roundNumber: 1, tick: 20, attackerIndex: null, victimIndex: 1, weapon: "awp", healthDamage: 90, healthDamageRaw: 90 },
48
+ { roundNumber: 1, tick: 20, attackerIndex: 0, victimIndex: 1, weapon: "ak47", healthDamage: 30, healthDamageRaw: 30 },
49
+ { roundNumber: 1, tick: 5, attackerIndex: 0, victimIndex: 1, weapon: "awp", healthDamage: 20, healthDamageRaw: 20 },
50
+ ],
51
+ } as unknown as DemoPackage;
52
+ const rows = tracks.map((track) => ({ teamKey: track.teamKey, playerIndex: track.playerIndex, freezeAwpOwnership: false, activeAwpSeconds: 0, awpShots: 0, awpKills: 0, availability: { replay: "available", nav: "missing", callouts: "missing", shots: "missing" } })) as unknown as import("@cs2dak/contract").PlayerPositionRoundFact[];
53
+ const facts = extractTeamAwpRoundFacts(pkg, "m1", context, rows);
54
+ expect(facts.find((row) => row.teamKey === "teamA")?.awpDamage).toBe(40);
55
+ expect(facts.find((row) => row.teamKey === "teamB")?.awpDamage).toBe(0);
56
+ });
57
+ });
@@ -0,0 +1,45 @@
1
+ import { decodeDelta, FLAG_ALIVE, type DemoPackage } from "@cs2dak/contract";
2
+ import { replayWeaponAt, type ReplayRoundContext, type ReplayRoundTrack } from "../tactics/replay-round-context.js";
3
+
4
+ function isAwp(weapon: string | null | undefined): boolean {
5
+ return weapon?.trim().toLowerCase().replace(/^weapon_/, "") === "awp";
6
+ }
7
+
8
+ export interface AwpRoundFacts {
9
+ freezeAwpOwnership: boolean | null;
10
+ activeAwpSeconds: number | null;
11
+ awpShots: number | null;
12
+ awpKills: number | null;
13
+ }
14
+
15
+ /** AWP is deliberately exact-name only: SSG 08 and other snipers do not qualify. */
16
+ export function extractAwpRoundFacts(
17
+ pkg: DemoPackage,
18
+ context: ReplayRoundContext | null,
19
+ track: ReplayRoundTrack | null,
20
+ ): AwpRoundFacts {
21
+ const playerIndex = track?.playerIndex ?? -1;
22
+ const roundNumber = context?.round.roundNumber ?? -1;
23
+ if (!context || !track) {
24
+ return { freezeAwpOwnership: null, activeAwpSeconds: null, awpShots: null, awpKills: null };
25
+ }
26
+ const economy = pkg.playerEconomies.find((row) => row.roundNumber === roundNumber && row.playerIndex === playerIndex);
27
+ const freezeAwpOwnership = economy ? isAwp(economy.primaryWeapon) : null;
28
+ const frameSeconds = context.tickStep / (pkg.match.tickrate || 64);
29
+ let activeFrames = 0;
30
+ for (let index = 0; index < context.frameCount; index += 1) {
31
+ const tick = context.startTick + index * context.tickStep;
32
+ if (tick < context.round.freezeEndTick || tick > context.round.endTick) continue;
33
+ if (((track.flags[index] ?? 0) & FLAG_ALIVE) === 0) continue;
34
+ if (![track.x[index], track.y[index], track.z[index]].every((value) => value != null && Number.isFinite(value))) continue;
35
+ if (isAwp(replayWeaponAt(context, track, index))) activeFrames += 1;
36
+ }
37
+ const shotTrack = pkg.shots?.tracks.find((row) => row.roundNumber === roundNumber && row.playerIndex === playerIndex);
38
+ const awpShots = shotTrack
39
+ ? decodeDelta(shotTrack.tick).filter((_, index) => isAwp(pkg.shots?.weaponDict[shotTrack.weapon[index] ?? -1])).length
40
+ : pkg.shots ? 0 : null;
41
+ const awpKills = pkg.kills
42
+ .filter((kill) => kill.roundNumber === roundNumber && kill.killerIndex === playerIndex)
43
+ .filter((kill) => isAwp(kill.killerActiveWeapon) || isAwp(kill.weapon)).length;
44
+ return { freezeAwpOwnership, activeAwpSeconds: activeFrames * frameSeconds, awpShots, awpKills };
45
+ }