@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.
- package/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +68 -88
- package/src/index.ts +41 -9
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +26 -149
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- 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
|
-
|
|
8
|
+
deriveRRSignals,
|
|
9
|
+
derivePlayerWeaponHighlights,
|
|
10
10
|
deriveRRIndicators,
|
|
11
11
|
loadDemoPackageFromZip
|
|
12
12
|
} from "./index";
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
56
|
-
expect(bundle.playerRoundFacts).toHaveLength(
|
|
57
|
-
expect(bundle.economy).toHaveLength(
|
|
58
|
-
expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(
|
|
59
|
-
expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(
|
|
60
|
-
expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(
|
|
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
|
|
72
|
-
const
|
|
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(
|
|
80
|
-
|
|
81
|
-
expect(signals[0]?.
|
|
82
|
-
expect(
|
|
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("
|
|
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).
|
|
95
|
-
|
|
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",
|
|
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",
|
|
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.
|
|
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
|
|
129
|
-
const
|
|
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",
|
|
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",
|
|
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(
|
|
117
|
+
expect(row.combatDeathCount).toBe(13);
|
|
156
118
|
expect(row.bombDeathCount).toBe(0);
|
|
157
|
-
expect(row.bombPlantCount).
|
|
119
|
+
expect(row.bombPlantCount).toBeGreaterThanOrEqual(0);
|
|
158
120
|
expect(row.noScopeKillCount).toBe(0);
|
|
159
|
-
expect(row.throughSmokeKillCount).toBe(
|
|
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("
|
|
172
|
-
const
|
|
173
|
-
const
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 {
|
|
13
|
-
export {
|
|
14
|
-
export {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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
|
-
|
|
46
|
-
|
|
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
|
+
}
|