@cs2dak/core 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +46 -53
  8. package/src/index.ts +25 -8
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +27 -1
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/signals.ts +149 -112
  35. package/src/spatial/annotate.test.ts +133 -0
  36. package/src/spatial/annotate.ts +163 -0
  37. package/src/spatial/index.ts +16 -0
  38. package/src/spatial/mapcontrol.test.ts +131 -0
  39. package/src/spatial/mapcontrol.ts +277 -0
  40. package/src/spatial/phase.test.ts +171 -0
  41. package/src/spatial/phase.ts +179 -0
  42. package/src/spatial/trade-closure.test.ts +38 -0
  43. package/src/spatial/types.ts +56 -0
  44. package/src/spatial/utility-geometry.test.ts +88 -0
  45. package/src/spatial/utility-geometry.ts +167 -0
  46. package/src/spatial/utility.integration.test.ts +38 -0
  47. package/src/spatial/utility.test.ts +120 -0
  48. package/src/spatial/utility.ts +399 -0
  49. package/src/tactics/formations.ts +151 -0
  50. package/src/tactics/index.ts +16 -0
  51. package/src/tactics/replay-round-context.ts +96 -0
  52. package/src/tactics/round-facts.ts +406 -0
  53. package/src/tactics/segments.ts +68 -0
  54. package/src/tactics/tactics.test.ts +112 -0
  55. package/src/tactics/types.ts +73 -0
  56. package/src/timeline.ts +73 -52
  57. package/src/utility-facts.test.ts +55 -0
  58. package/src/utility-facts.ts +137 -0
  59. package/src/utils.ts +27 -25
  60. package/src/weapon-highlights.ts +4 -4
  61. package/src/economy.test.ts +0 -37
  62. package/src/economy.ts +0 -57
package/src/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
- deriveAccountSignalsV2,
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 strict v2 export", async () => {
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/2.0");
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.sourceSchemaVersion).toBe("cs2-demo-format/2.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");
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(16);
25
- expect(bundle.playerRoundFacts).toHaveLength(160);
26
- expect(bundle.economy).toHaveLength(16);
27
- expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(119);
28
- expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(51);
29
- expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(119);
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 value-account signals and computes v2 RR from the strict v2 fixture", async () => {
37
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
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(16);
45
- expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 2, even: 3, advantage: 8 });
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("value-accounts-v2-lite");
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("anchors accountRR so the per-match league mean is ~1.0", async () => {
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).toBeCloseTo(1.0, 2);
60
- // 锚定后必然有人高于、有人低于 1.0
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", async () => {
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", async () => {
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.steamId64 === statsTruth.steamId64 ? patchedStats : 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 bombDeathIndicators = indicators.find((row) => row.steamId64 === bombDeathStats.steamId64)!;
94
- 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)!;
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", async () => {
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", async () => {
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(9);
117
+ expect(row.combatDeathCount).toBe(13);
121
118
  expect(row.bombDeathCount).toBe(0);
122
- expect(row.bombPlantCount).toBe(0);
119
+ expect(row.bombPlantCount).toBeGreaterThanOrEqual(0);
123
120
  expect(row.noScopeKillCount).toBe(0);
124
- expect(row.throughSmokeKillCount).toBe(0);
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", async () => {
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", async () => {
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 = deriveAccountSignalsV2({ ...pkg, playerEconomies: [] });
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 = deriveAccountSignalsV2({ ...pkg, rounds: [] });
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 { 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";
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 type {
17
- EconomyTypeStats,
18
- EconomyConversion,
19
- MatchEconomyConversion,
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 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
+ }
@@ -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
+ });