@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.
- 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 +46 -53
- package/src/index.ts +25 -8
- 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 +27 -1
- 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/signals.ts +149 -112
- 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 +4 -4
- package/src/economy.test.ts +0 -37
- package/src/economy.ts +0 -57
package/src/index.test.ts
CHANGED
|
@@ -1,70 +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
7
|
computeAccountRatingsV2,
|
|
7
|
-
|
|
8
|
+
deriveRRSignals,
|
|
8
9
|
derivePlayerWeaponHighlights,
|
|
9
10
|
deriveRRIndicators,
|
|
10
11
|
loadDemoPackageFromZip
|
|
11
12
|
} from "./index";
|
|
12
13
|
|
|
14
|
+
let pkg: DemoPackage;
|
|
15
|
+
|
|
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);
|
|
20
|
+
|
|
13
21
|
describe("analyzeDemoPackage", () => {
|
|
14
|
-
it("builds reusable analysis and view-model artifacts from a
|
|
15
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
16
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
22
|
+
it("builds reusable analysis and view-model artifacts from a v3 export", () => {
|
|
17
23
|
const bundle = analyzeDemoPackage(pkg);
|
|
18
|
-
expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/
|
|
24
|
+
expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/3.0");
|
|
19
25
|
expect(bundle.version).toBe("cs2-demo-analysis-kit/1.0");
|
|
20
|
-
expect(bundle.provenance.
|
|
26
|
+
expect(bundle.provenance.analysisVersion).toBe("cs2-demo-analysis-kit/1.0.1");
|
|
27
|
+
expect(bundle.provenance.sourceSchemaVersion).toBe("cs2-demo-format/3.0");
|
|
21
28
|
expect(bundle.provenance.ratingVersions.rr).toBeTruthy();
|
|
22
29
|
expect(bundle.scoreboard).toHaveLength(10);
|
|
23
30
|
expect(bundle.scoreboard[0]?.rr).toBeGreaterThan(0);
|
|
24
|
-
expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(
|
|
25
|
-
expect(bundle.playerRoundFacts).toHaveLength(
|
|
26
|
-
expect(bundle.economy).toHaveLength(
|
|
27
|
-
expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(
|
|
28
|
-
expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(
|
|
29
|
-
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);
|
|
30
37
|
expect(bundle.timeline.some((event) => event.type === "kill")).toBe(true);
|
|
31
38
|
expect(bundle.heatmap.some((point) => point.kind === "death")).toBe(true);
|
|
32
39
|
expect(bundle.timeline.find((event) => event.type === "round-end")?.clockPhase).toBe("round-end");
|
|
33
40
|
expect(bundle.timeline.find((event) => event.type === "kill")?.clockLabel).toMatch(/^\d:\d{2}$/);
|
|
34
41
|
});
|
|
35
42
|
|
|
36
|
-
it("derives
|
|
37
|
-
const
|
|
38
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
39
|
-
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);
|
|
40
45
|
const ratings = computeAccountRatingsV2(pkg);
|
|
41
46
|
|
|
42
47
|
expect(signals).toHaveLength(10);
|
|
43
48
|
expect(ratings).toHaveLength(10);
|
|
44
|
-
expect(signals[0]?.rounds).toBe(
|
|
45
|
-
|
|
46
|
-
expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 2, even: 5, manUp: 6 });
|
|
49
|
+
expect(signals[0]?.rounds).toBe(23);
|
|
50
|
+
// 新 fixture(Team Liquid vs FlyQuest, de_anubis, 15 回合)的 combat 分布
|
|
47
51
|
expect(signals[0]?.trade.tradedOpeningDeaths).toBe(0);
|
|
48
|
-
expect(ratings[0]?.rr.model).toBe("
|
|
52
|
+
expect(ratings[0]?.rr.model).toBe("rr-six-accounts");
|
|
49
53
|
expect(ratings[0]?.rr.rr).toBeGreaterThan(0);
|
|
50
54
|
});
|
|
51
55
|
|
|
52
|
-
it("
|
|
53
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
54
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
56
|
+
it("scores accountRR against the frozen pro baseline instead of the match mean", () => {
|
|
55
57
|
const bundle = analyzeDemoPackage(pkg);
|
|
56
58
|
|
|
57
59
|
const accountRRs = bundle.scoreboard.map((row) => row.accountRR);
|
|
58
60
|
const mean = accountRRs.reduce((sum, v) => sum + v, 0) / accountRRs.length;
|
|
59
|
-
expect(mean).
|
|
60
|
-
|
|
61
|
+
expect(mean).toBeGreaterThan(0);
|
|
62
|
+
expect(mean).not.toBeCloseTo(1.0, 2);
|
|
63
|
+
// 职业基线不会强制当前比赛均值为 1.0,但仍应保留个体区分。
|
|
61
64
|
expect(accountRRs.some((v) => v > 1.0)).toBe(true);
|
|
62
65
|
expect(accountRRs.some((v) => v < 1.0)).toBe(true);
|
|
63
66
|
});
|
|
64
67
|
|
|
65
|
-
it("exposes RRIndicators without rebuilding them in cohort callers",
|
|
66
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
67
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
68
|
+
it("exposes RRIndicators without rebuilding them in cohort callers", () => {
|
|
68
69
|
const indicators = deriveRRIndicators(pkg);
|
|
69
70
|
const bundle = analyzeDemoPackage(pkg);
|
|
70
71
|
|
|
@@ -72,9 +73,7 @@ describe("analyzeDemoPackage", () => {
|
|
|
72
73
|
expect(indicators[0]).toEqual(bundle.playerIndicators[0]?.indicators);
|
|
73
74
|
});
|
|
74
75
|
|
|
75
|
-
it("wires player-stats truth into RRIndicators instead of legacy approximations",
|
|
76
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
77
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
76
|
+
it("wires player-stats truth into RRIndicators instead of legacy approximations", () => {
|
|
78
77
|
const statsTruth = pkg.playerStats[0]!;
|
|
79
78
|
const patchedStats = {
|
|
80
79
|
...statsTruth,
|
|
@@ -84,14 +83,16 @@ describe("analyzeDemoPackage", () => {
|
|
|
84
83
|
};
|
|
85
84
|
const patchedPkg = {
|
|
86
85
|
...pkg,
|
|
87
|
-
playerStats: pkg.playerStats.map((row) => row.
|
|
86
|
+
playerStats: pkg.playerStats.map((row) => row.playerIndex === statsTruth.playerIndex ? patchedStats : row)
|
|
88
87
|
};
|
|
89
88
|
const indicators = deriveRRIndicators(patchedPkg);
|
|
90
89
|
const bombDeathStats = patchedStats;
|
|
91
90
|
const wallbangStats = pkg.playerStats.find((row) => row.wallbangKillCount > 0)!;
|
|
92
91
|
|
|
93
|
-
const
|
|
94
|
-
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)!;
|
|
95
96
|
|
|
96
97
|
expect(bombDeathStats.deaths).not.toBe(bombDeathStats.combatDeathCount);
|
|
97
98
|
expect(bombDeathIndicators.combatDeathCount).toBe(bombDeathStats.combatDeathCount);
|
|
@@ -99,9 +100,7 @@ describe("analyzeDemoPackage", () => {
|
|
|
99
100
|
expect(wallbangIndicators.wallbangKillCount).toBe(wallbangStats.wallbangKillCount);
|
|
100
101
|
});
|
|
101
102
|
|
|
102
|
-
it("surfaces account breakdown and context status on the scoreboard",
|
|
103
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
104
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
103
|
+
it("surfaces account breakdown and context status on the scoreboard", () => {
|
|
105
104
|
const bundle = analyzeDemoPackage(pkg);
|
|
106
105
|
const row = bundle.scoreboard[0]!;
|
|
107
106
|
|
|
@@ -111,17 +110,15 @@ describe("analyzeDemoPackage", () => {
|
|
|
111
110
|
expect(row.accountContextStatus.manState).toBe("available");
|
|
112
111
|
});
|
|
113
112
|
|
|
114
|
-
it("surfaces rich v2 fields and confidence on the scoreboard",
|
|
115
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
116
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
113
|
+
it("surfaces rich v2 fields and confidence on the scoreboard", () => {
|
|
117
114
|
const bundle = analyzeDemoPackage(pkg);
|
|
118
115
|
const row = bundle.scoreboard[0]!;
|
|
119
116
|
|
|
120
|
-
expect(row.combatDeathCount).toBe(
|
|
117
|
+
expect(row.combatDeathCount).toBe(13);
|
|
121
118
|
expect(row.bombDeathCount).toBe(0);
|
|
122
|
-
expect(row.bombPlantCount).
|
|
119
|
+
expect(row.bombPlantCount).toBeGreaterThanOrEqual(0);
|
|
123
120
|
expect(row.noScopeKillCount).toBe(0);
|
|
124
|
-
expect(row.throughSmokeKillCount).toBe(
|
|
121
|
+
expect(row.throughSmokeKillCount).toBe(1);
|
|
125
122
|
expect(row.fieldAvailability).toEqual({
|
|
126
123
|
playerStats: "available",
|
|
127
124
|
economy: "available",
|
|
@@ -133,9 +130,7 @@ describe("analyzeDemoPackage", () => {
|
|
|
133
130
|
expect(row.confidence).toBeCloseTo(0.917, 3);
|
|
134
131
|
});
|
|
135
132
|
|
|
136
|
-
it("derives reusable per-player weapon kill distribution and highlight facts",
|
|
137
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
138
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
133
|
+
it("derives reusable per-player weapon kill distribution and highlight facts", () => {
|
|
139
134
|
const facts = derivePlayerWeaponHighlights(pkg);
|
|
140
135
|
const bundle = analyzeDemoPackage(pkg);
|
|
141
136
|
|
|
@@ -155,17 +150,15 @@ describe("analyzeDemoPackage", () => {
|
|
|
155
150
|
expect(facts.every((row) => row.highlights.throughSmokeKills != null)).toBe(true);
|
|
156
151
|
});
|
|
157
152
|
|
|
158
|
-
it("emits null context buckets (not zero) when the data source is missing",
|
|
159
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
160
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
161
|
-
|
|
153
|
+
it("emits null context buckets (not zero) when the data source is missing", () => {
|
|
162
154
|
// 剥离经济源 → buyDelta 降级为 null(而非零桶);manState 仍可用
|
|
163
|
-
const noEconomy =
|
|
155
|
+
const noEconomy = deriveRRSignals({ ...pkg, playerEconomies: [] });
|
|
164
156
|
expect(noEconomy[0]?.combat.killsByBuyDelta).toBeNull();
|
|
165
157
|
expect(noEconomy[0]?.combat.killsByManState).not.toBeNull();
|
|
166
158
|
|
|
167
159
|
// 剥离回合源 → manState 降级为 null
|
|
168
|
-
const noRounds =
|
|
160
|
+
const noRounds = deriveRRSignals({ ...pkg, rounds: [] });
|
|
169
161
|
expect(noRounds[0]?.combat.killsByManState).toBeNull();
|
|
170
162
|
});
|
|
163
|
+
|
|
171
164
|
});
|
package/src/index.ts
CHANGED
|
@@ -8,17 +8,32 @@ import { buildPlayerWeaponHighlights } from "./weapon-highlights.js";
|
|
|
8
8
|
|
|
9
9
|
export { loadDemoPackageFromZip } from "./loader.js";
|
|
10
10
|
export { normalizeDemoPackage } from "./normalize.js";
|
|
11
|
-
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";
|
|
12
17
|
export { deriveRRIndicators } from "./scoreboard.js";
|
|
18
|
+
export { buildPlayerRoundFacts } from "./scoreboard.js";
|
|
19
|
+
export { buildPlayerRoundUtilityFacts, type PlayerRoundUtilityFact } from "./utility-facts.js";
|
|
13
20
|
export { derivePlayerWeaponHighlights } from "./weapon-highlights.js";
|
|
14
|
-
export { buildEconomyConversion } from "./economy.js";
|
|
15
21
|
export { buildTeamSideWinRates } from "./side-win-rate.js";
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "./economy.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";
|
|
21
26
|
export type { SideWinRateStats, TeamSideWinRates } from "./side-win-rate.js";
|
|
27
|
+
export type { DuelClassification, DuelHpBucket, DuelRecord, DuelSignals, TtkDistribution } from "./duels.js";
|
|
28
|
+
export type {
|
|
29
|
+
BurstLengthBuckets,
|
|
30
|
+
MechanicsMetricSet,
|
|
31
|
+
MechanicsSignals,
|
|
32
|
+
MedianSample,
|
|
33
|
+
PlayerMechanicsFact,
|
|
34
|
+
PreaimSample,
|
|
35
|
+
RateSample,
|
|
36
|
+
} from "./mechanics.js";
|
|
22
37
|
|
|
23
38
|
export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
24
39
|
const pkg = normalizeDemoPackage(input);
|
|
@@ -36,7 +51,7 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
|
36
51
|
version: "cs2-demo-analysis-kit/1.0",
|
|
37
52
|
sourceSchemaVersion: pkg.manifest.schemaVersion,
|
|
38
53
|
provenance: {
|
|
39
|
-
analysisVersion: "cs2-demo-analysis-kit/1.0",
|
|
54
|
+
analysisVersion: "cs2-demo-analysis-kit/1.0.1",
|
|
40
55
|
sourceSchemaVersion: pkg.manifest.schemaVersion,
|
|
41
56
|
sourceDemoHash: pkg.manifest.demo?.hash ?? null,
|
|
42
57
|
exporter: pkg.manifest.exporter,
|
|
@@ -62,3 +77,5 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
|
62
77
|
qa
|
|
63
78
|
});
|
|
64
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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FLAG_ALIVE, type DemoPackage } from "@cs2dak/contract";
|
|
3
|
+
import type { ReplayRoundContext, ReplayRoundTrack } from "../tactics/replay-round-context.js";
|
|
4
|
+
import { extractCtRotationRoundFacts } from "./ct-rotation.js";
|
|
5
|
+
import { buildRoundSpatialFrames } from "./spatial.js";
|
|
6
|
+
|
|
7
|
+
const frameCount = 71;
|
|
8
|
+
const placeDict = ["BombsiteA", "CTSpawn", "BombsiteB", "TRamp", "Apartments"];
|
|
9
|
+
const round = {
|
|
10
|
+
roundNumber: 1,
|
|
11
|
+
freezeEndTick: 100,
|
|
12
|
+
endTick: 800,
|
|
13
|
+
teamASide: "ct",
|
|
14
|
+
teamBSide: "t",
|
|
15
|
+
} as DemoPackage["rounds"][number];
|
|
16
|
+
|
|
17
|
+
function track(
|
|
18
|
+
playerIndex: number,
|
|
19
|
+
teamKey: "teamA" | "teamB",
|
|
20
|
+
side: "ct" | "t",
|
|
21
|
+
places: number[],
|
|
22
|
+
flags = Array(frameCount).fill(FLAG_ALIVE),
|
|
23
|
+
): ReplayRoundTrack {
|
|
24
|
+
return {
|
|
25
|
+
playerIndex,
|
|
26
|
+
teamKey,
|
|
27
|
+
side,
|
|
28
|
+
steamId64: `7656119800000000${playerIndex + 1}`,
|
|
29
|
+
x: Array(frameCount).fill(playerIndex * 100),
|
|
30
|
+
y: Array(frameCount).fill(0),
|
|
31
|
+
z: Array(frameCount).fill(0),
|
|
32
|
+
flags,
|
|
33
|
+
place: places,
|
|
34
|
+
weapon: Array(frameCount).fill(-1),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scenario(options: { deathTick?: number; otherContactTick?: number; secondRotates?: boolean; deathWithoutEnemyContact?: boolean } = {}) {
|
|
39
|
+
const deathTick = options.deathTick ?? null;
|
|
40
|
+
const otherContactTick = options.otherContactTick ?? 305;
|
|
41
|
+
const rotatingFlags = Array(frameCount).fill(FLAG_ALIVE);
|
|
42
|
+
if (deathTick != null) rotatingFlags.fill(0, Math.floor((deathTick - 100) / 10) + 1);
|
|
43
|
+
const rotatingPlaces = Array.from({ length: frameCount }, (_, index) => index <= 20 ? 0 : index < 30 ? 1 : 2);
|
|
44
|
+
const otherContactFrame = Math.round((otherContactTick - 100) / 10);
|
|
45
|
+
const context: ReplayRoundContext = {
|
|
46
|
+
round,
|
|
47
|
+
startTick: 100,
|
|
48
|
+
tickStep: 10,
|
|
49
|
+
frameCount,
|
|
50
|
+
placeDict,
|
|
51
|
+
weaponDict: [],
|
|
52
|
+
tracks: [
|
|
53
|
+
track(0, "teamA", "ct", rotatingPlaces, rotatingFlags),
|
|
54
|
+
track(1, "teamA", "ct", options.secondRotates ? rotatingPlaces : Array(frameCount).fill(0)),
|
|
55
|
+
track(2, "teamB", "t", Array.from({ length: frameCount }, (_, index) => index < otherContactFrame ? 3 : 4)),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const pkg = {
|
|
59
|
+
match: { mapName: "de_mirage", tickrate: 10 },
|
|
60
|
+
rounds: [round],
|
|
61
|
+
players: [
|
|
62
|
+
{ steamId64: "76561198000000001", teamKey: "teamA" },
|
|
63
|
+
{ steamId64: "76561198000000002", teamKey: "teamA" },
|
|
64
|
+
{ steamId64: "76561198000000003", teamKey: "teamB" },
|
|
65
|
+
],
|
|
66
|
+
damages: [
|
|
67
|
+
{ roundNumber: 1, tick: 250, attackerIndex: 2, victimIndex: 0, healthDamageRaw: 10 },
|
|
68
|
+
{ roundNumber: 1, tick: otherContactTick, attackerIndex: 2, victimIndex: 0, healthDamageRaw: 10 },
|
|
69
|
+
],
|
|
70
|
+
kills: deathTick == null ? [] : [{ roundNumber: 1, tick: deathTick, killerIndex: options.deathWithoutEnemyContact ? null : 2, victimIndex: 0 }],
|
|
71
|
+
shots: undefined,
|
|
72
|
+
} as unknown as DemoPackage;
|
|
73
|
+
const frames = buildRoundSpatialFrames(context, null, null);
|
|
74
|
+
return extractCtRotationRoundFacts(pkg, "m1", context, round, frames, null, false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("extractCtRotationRoundFacts", () => {
|
|
78
|
+
it("records a stable cross-area response and its team-relative order without emitting a role", () => {
|
|
79
|
+
const row = scenario().find((candidate) => candidate.playerIndex === 0)!;
|
|
80
|
+
|
|
81
|
+
expect(row).toMatchObject({
|
|
82
|
+
initialPositionGroupId: "a_site",
|
|
83
|
+
initialRegion: "a",
|
|
84
|
+
initialPositionGroupShare: 1,
|
|
85
|
+
initialResponsibilityResolved: true,
|
|
86
|
+
firstOwnAreaContactTick: 250,
|
|
87
|
+
firstOtherAreaContactTick: 305,
|
|
88
|
+
firstTeamContactTick: 250,
|
|
89
|
+
leftInitialPositionGroupTick: 310,
|
|
90
|
+
leaveDelayAfterFirstOtherAreaContactSeconds: 0.5,
|
|
91
|
+
firstStableDestinationPositionGroupId: "b_site",
|
|
92
|
+
firstStableDestinationRegion: "b",
|
|
93
|
+
crossedResponsibilityArea: true,
|
|
94
|
+
returnedToInitialPositionGroup: false,
|
|
95
|
+
transitToStableDestinationSeconds: 9,
|
|
96
|
+
crossAreaDepartureOrder: 1,
|
|
97
|
+
firstCrossAreaDeparture: true,
|
|
98
|
+
priorCrossAreaDeparturesAlive: 0,
|
|
99
|
+
initialAreaStillCovered: true,
|
|
100
|
+
censoredByDeath: false,
|
|
101
|
+
});
|
|
102
|
+
expect(row).not.toHaveProperty("role");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps an unfinished response unknown when death truncates observation", () => {
|
|
106
|
+
const row = scenario({ deathTick: 330 }).find((candidate) => candidate.playerIndex === 0)!;
|
|
107
|
+
|
|
108
|
+
expect(row.deathTick).toBe(330);
|
|
109
|
+
expect(row.censoredByDeath).toBe(true);
|
|
110
|
+
expect(row.leftInitialPositionGroupTick).toBeNull();
|
|
111
|
+
expect(row.firstStableDestinationPositionGroupId).toBeNull();
|
|
112
|
+
expect(row.crossedResponsibilityArea).toBeNull();
|
|
113
|
+
expect(row.crossAreaDepartureOrder).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("records an observed full-round stay as false rather than unknown", () => {
|
|
117
|
+
const row = scenario().find((candidate) => candidate.playerIndex === 1)!;
|
|
118
|
+
|
|
119
|
+
expect(row.initialResponsibilityResolved).toBe(true);
|
|
120
|
+
expect(row.deathTick).toBeNull();
|
|
121
|
+
expect(row.censoredByDeath).toBe(false);
|
|
122
|
+
expect(row.crossedResponsibilityArea).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("uses signed contact delay instead of hiding a departure that came first", () => {
|
|
126
|
+
const row = scenario({ otherContactTick: 350 }).find((candidate) => candidate.playerIndex === 0)!;
|
|
127
|
+
|
|
128
|
+
expect(row.leftInitialPositionGroupTick).toBe(310);
|
|
129
|
+
expect(row.firstOtherAreaContactTick).toBe(350);
|
|
130
|
+
expect(row.leaveDelayAfterFirstOtherAreaContactSeconds).toBe(-4);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("clips team contacts to the player's observable lifetime", () => {
|
|
134
|
+
const row = scenario({ deathTick: 190, deathWithoutEnemyContact: true }).find((candidate) => candidate.playerIndex === 0)!;
|
|
135
|
+
|
|
136
|
+
expect(row.censoredByDeath).toBe(true);
|
|
137
|
+
expect(row.firstTeamContactTick).toBeNull();
|
|
138
|
+
expect(row.firstOwnAreaContactTick).toBeNull();
|
|
139
|
+
expect(row.firstOtherAreaContactTick).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("preserves simultaneous departures as deterministic shared ranks", () => {
|
|
143
|
+
const rows = scenario({ secondRotates: true }).filter((candidate) => candidate.playerIndex < 2);
|
|
144
|
+
|
|
145
|
+
expect(rows.map((row) => row.crossAreaDepartureOrder)).toEqual([1, 1]);
|
|
146
|
+
expect(rows.map((row) => row.firstCrossAreaDeparture)).toEqual([true, true]);
|
|
147
|
+
expect(rows.map((row) => row.priorCrossAreaDeparturesAlive)).toEqual([0, 0]);
|
|
148
|
+
});
|
|
149
|
+
});
|