@cs2dak/core 0.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +68 -88
  8. package/src/index.ts +41 -9
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +26 -149
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/side-win-rate.test.ts +33 -0
  35. package/src/side-win-rate.ts +49 -0
  36. package/src/signals.ts +150 -113
  37. package/src/spatial/annotate.test.ts +133 -0
  38. package/src/spatial/annotate.ts +163 -0
  39. package/src/spatial/index.ts +16 -0
  40. package/src/spatial/mapcontrol.test.ts +131 -0
  41. package/src/spatial/mapcontrol.ts +277 -0
  42. package/src/spatial/phase.test.ts +171 -0
  43. package/src/spatial/phase.ts +179 -0
  44. package/src/spatial/trade-closure.test.ts +38 -0
  45. package/src/spatial/types.ts +56 -0
  46. package/src/spatial/utility-geometry.test.ts +88 -0
  47. package/src/spatial/utility-geometry.ts +167 -0
  48. package/src/spatial/utility.integration.test.ts +38 -0
  49. package/src/spatial/utility.test.ts +120 -0
  50. package/src/spatial/utility.ts +399 -0
  51. package/src/tactics/formations.ts +151 -0
  52. package/src/tactics/index.ts +16 -0
  53. package/src/tactics/replay-round-context.ts +96 -0
  54. package/src/tactics/round-facts.ts +406 -0
  55. package/src/tactics/segments.ts +68 -0
  56. package/src/tactics/tactics.test.ts +112 -0
  57. package/src/tactics/types.ts +73 -0
  58. package/src/timeline.ts +73 -52
  59. package/src/utility-facts.test.ts +55 -0
  60. package/src/utility-facts.ts +137 -0
  61. package/src/utils.ts +27 -25
  62. package/src/weapon-highlights.ts +55 -0
  63. package/src/economy.test.ts +0 -51
  64. package/src/economy.ts +0 -76
  65. package/src/weapons.test.ts +0 -32
  66. package/src/weapons.ts +0 -93
  67. package/src/workspace.ts +0 -726
package/src/normalize.ts CHANGED
@@ -1,158 +1,35 @@
1
- import { demoPackageSchema, type DemoPackage, type TeamKey } from "@cs2dak/contract";
1
+ import { demoPackageSchema, type DemoPackage } from "@cs2dak/contract";
2
2
 
3
3
  export function parsePackageJson(text: string): unknown {
4
4
  return JSON.parse(text.replace(/\bNaN\b/g, "null"));
5
5
  }
6
6
 
7
- export function normalizeDemoPackage(input: unknown): DemoPackage {
8
- const raw = input as Record<string, unknown>;
9
- const manifest = raw.manifest as Record<string, unknown> | undefined;
10
-
11
- if (manifest?.schemaVersion === "cs2-demo-format/1.0") {
12
- return demoPackageSchema.parse(normalizeV1Package(raw));
13
- }
14
-
15
- return demoPackageSchema.parse(input);
16
- }
17
-
18
- function normalizeV1Package(raw: Record<string, unknown>): Record<string, unknown> {
19
- const match = raw.match as Record<string, unknown>;
20
- const rounds = asRecords(raw.rounds).filter((round) => numberValue(round.roundNumber) > 0);
21
- const roundByNumber = new Map(rounds.map((round) => [numberValue(round.roundNumber), round]));
22
-
23
- return {
24
- manifest: {
25
- ...(raw.manifest as Record<string, unknown>),
26
- schemaVersion: "cs2-demo-format/2.0"
27
- },
28
- match: {
29
- ...match,
30
- durationSeconds: numberValue(match.durationSeconds) > 0 ? match.durationSeconds : undefined
31
- },
32
- players: raw.players,
33
- rounds: rounds.map((round) => normalizeV1Round(round)),
34
- playerEconomies: asRecords(raw.playerEconomies)
35
- .filter((row) => numberValue(row.roundNumber) > 0)
36
- .map((row) => ({ ...row, type: normalizeEconomyType(row.type) })),
37
- playerStats: raw.playerStats ?? [],
38
- kills: asRecords(raw.kills)
39
- .filter((kill) => numberValue(kill.roundNumber) > 0)
40
- .map((kill) => normalizeV1Kill(kill, roundByNumber)),
41
- damages: asRecords(raw.damages)
42
- .filter((row) => numberValue(row.roundNumber) > 0)
43
- .map((row) => normalizeV1Damage(row, roundByNumber)),
44
- blinds: asRecords(raw.blinds).filter((row) => numberValue(row.roundNumber) > 0),
45
- grenades: asRecords(raw.grenades)
46
- .filter((row) => numberValue(row.roundNumber) > 0)
47
- .map((row) => normalizeV1Grenade(row, roundByNumber)),
48
- clutches: asRecords(raw.clutches).filter((row) => numberValue(row.roundNumber) > 0)
49
- };
50
- }
51
-
52
- function asRecords(value: unknown): Record<string, unknown>[] {
53
- return Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object" && !Array.isArray(row)) : [];
54
- }
55
-
56
- function normalizeV1Round(round: Record<string, unknown>): Record<string, unknown> {
57
- return {
58
- ...round,
59
- startTick: positiveInt(round.startTick),
60
- freezeEndTick: positiveInt(round.freezeEndTick),
61
- endTick: positiveInt(round.endTick),
62
- teamASide: normalizeSide(round.teamASide) ?? "t",
63
- teamBSide: normalizeSide(round.teamBSide) ?? "ct",
64
- teamAEconomy: normalizeEconomyType(round.teamAEconomy),
65
- teamBEconomy: normalizeEconomyType(round.teamBEconomy),
66
- winnerSide: normalizeSide(round.winnerSide) ?? sideForTeam(round.winnerTeamKey, round) ?? "t"
67
- };
68
- }
69
-
70
- function normalizeV1Kill(kill: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
71
- const round = rounds.get(numberValue(kill.roundNumber));
72
- return {
73
- ...kill,
74
- tick: positiveInt(kill.tick),
75
- killerTeamKey: normalizeTeamKey(kill.killerTeamKey),
76
- victimTeamKey: normalizeTeamKey(kill.victimTeamKey) ?? "teamA",
77
- killerSide: normalizeSide(kill.killerSide) ?? sideForTeam(kill.killerTeamKey, round),
78
- victimSide: normalizeSide(kill.victimSide) ?? sideForTeam(kill.victimTeamKey, round) ?? "t",
79
- killerPosition: sanitizeNullablePosition(kill.killerPosition),
80
- victimPosition: sanitizePosition(kill.victimPosition)
81
- };
82
- }
83
-
84
- function normalizeV1Damage(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
85
- const round = rounds.get(numberValue(row.roundNumber));
86
- return {
87
- ...row,
88
- tick: positiveInt(row.tick),
89
- weapon: typeof row.weapon === "string" && row.weapon.length > 0 ? row.weapon : "unknown",
90
- attackerTeamKey: normalizeTeamKey(row.attackerTeamKey),
91
- victimTeamKey: normalizeTeamKey(row.victimTeamKey) ?? "teamA",
92
- attackerSide: normalizeSide(row.attackerSide) ?? sideForTeam(row.attackerTeamKey, round),
93
- victimSide: normalizeSide(row.victimSide) ?? sideForTeam(row.victimTeamKey, round) ?? "t",
94
- attackerPosition: sanitizeNullablePosition(row.attackerPosition),
95
- victimPosition: sanitizeNullablePosition(row.victimPosition) ?? undefined
96
- };
97
- }
98
-
99
- function normalizeV1Grenade(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
100
- const round = rounds.get(numberValue(row.roundNumber));
101
- const teamKey = normalizeTeamKey(row.throwerTeamKey ?? row.teamKey);
102
- return {
103
- roundNumber: row.roundNumber,
104
- tick: positiveInt(row.effectTick ?? row.throwTick ?? row.tick),
105
- steamId64: row.throwerSteamId64 ?? row.steamId64 ?? null,
106
- teamKey,
107
- side: normalizeSide(row.throwerSide ?? row.side) ?? sideForTeam(teamKey, round),
108
- grenadeType: typeof row.grenade === "string" && row.grenade.length > 0 ? row.grenade : row.grenadeType ?? "unknown",
109
- eventType: row.eventType ?? "effect",
110
- position: sanitizeNullablePosition(row.effectPosition ?? row.throwPosition ?? row.position)
111
- };
112
- }
7
+ /**
8
+ * 已校验过的 DemoPackage 打标,避免对同一对象重复跑整包 zod 解析。
9
+ * 单场 facts 抽取里 buildOpeningTrails(每选手一次)/ workspace / 各 extract* 都会
10
+ * 再调 normalizeDemoPackage(pkg);已规范化对象直接复用即可(zod parse 对已验证对象幂等)。
11
+ */
12
+ const normalized = new WeakSet<object>();
113
13
 
114
- function sideForTeam(teamKey: unknown, round: Record<string, unknown> | undefined): "t" | "ct" | null {
115
- const team = normalizeTeamKey(teamKey);
116
- if (!team || !round) {
117
- return null;
118
- }
119
- return normalizeSide(team === "teamA" ? round.teamASide : round.teamBSide);
120
- }
121
-
122
- function normalizeTeamKey(value: unknown): TeamKey | null {
123
- return value === "teamA" || value === "teamB" ? value : null;
124
- }
125
-
126
- function normalizeSide(value: unknown): "t" | "ct" | null {
127
- return value === "t" || value === "ct" ? value : null;
128
- }
129
-
130
- function normalizeEconomyType(value: unknown): "pistol" | "eco" | "semi" | "force" | "full" | "conversion" {
131
- return value === "pistol" || value === "eco" || value === "semi" || value === "force" || value === "full" || value === "conversion" ? value : "full";
132
- }
14
+ /**
15
+ * 重型数组(replay ~17MB / duels ~11MB)排除在 zod 校验之外:上游 cs2-demo-format 的
16
+ * replay/duels schema 是纯校验(无 transform/refine/coerce),JSON.parse 出来即最终形状,
17
+ * 跑 zod 只是把它们整张深拷贝一遍——这正是单场导入的内存峰值主因(graph#1 原图 +
18
+ * graph#2 zod 拷贝同时存在)。校验其余小/中型 section,replay/duels 按引用挂回,
19
+ * 避免百 MB 级深拷贝,单场峰值大致砍半。
20
+ */
21
+ const leanSchema = demoPackageSchema.omit({ replay: true, duels: true });
133
22
 
134
- function numberValue(value: unknown): number {
135
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
136
- }
137
-
138
- function positiveInt(value: unknown): number {
139
- return Math.max(1, Math.trunc(numberValue(value)));
140
- }
141
-
142
- function sanitizeNullablePosition(value: unknown): { x: number; y: number; z: number } | null {
143
- if (!value || typeof value !== "object" || Array.isArray(value)) {
144
- return null;
145
- }
146
- const point = value as Record<string, unknown>;
147
- const x = numberValue(point.x);
148
- const y = numberValue(point.y);
149
- const z = numberValue(point.z);
150
- if (x === 0 && y === 0 && z === 0) {
151
- return null;
23
+ export function normalizeDemoPackage(input: unknown): DemoPackage {
24
+ if (typeof input === "object" && input !== null && normalized.has(input)) {
25
+ return input as DemoPackage;
152
26
  }
153
- return { x, y, z };
154
- }
155
-
156
- function sanitizePosition(value: unknown): { x: number; y: number; z: number } {
157
- return sanitizeNullablePosition(value) ?? { x: 0, y: 0, z: 0 };
27
+ const raw = (input ?? {}) as { replay?: unknown; duels?: unknown };
28
+ const { replay, duels } = raw;
29
+ // leanSchema 会 strip 掉 replay/duels;校验完按引用挂回原数组,不深拷贝。
30
+ const pkg = leanSchema.parse(input) as DemoPackage;
31
+ if (replay !== undefined) (pkg as { replay?: unknown }).replay = replay;
32
+ if (duels !== undefined) (pkg as { duels?: unknown }).duels = duels;
33
+ normalized.add(pkg);
34
+ return pkg;
158
35
  }
package/src/qa.test.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, PackageDamage } from "@cs2dak/contract";
3
+ import { activeDamages } from "./utils.js";
4
+ import { buildQaReport, demoSourceAvailability } from "./qa.js";
5
+
6
+ function damage(tick: number): PackageDamage {
7
+ return {
8
+ roundNumber: 1,
9
+ tick,
10
+ attackerIndex: 0,
11
+ victimIndex: 1,
12
+ weapon: "ak47",
13
+ hitgroup: "head",
14
+ healthDamage: 12,
15
+ healthDamageRaw: 12,
16
+ armorDamage: 0,
17
+ victimHealthBefore: 100,
18
+ victimArmorAfter: 100,
19
+ attackerPosition: { x: 0, y: 0, z: 0 },
20
+ victimPosition: { x: 100, y: 0, z: 0 }
21
+ };
22
+ }
23
+
24
+ function pkg(overrides: Partial<DemoPackage>): DemoPackage {
25
+ return {
26
+ manifest: {
27
+ schemaVersion: "cs2-demo-format/3.0",
28
+ exporter: { name: "test", version: "0" },
29
+ parser: { name: "test", version: "0" },
30
+ demo: { hash: null, sourceFileName: null },
31
+ mapName: "de_mirage",
32
+ tickrate: 64,
33
+ exportedAt: "2026-01-01T00:00:00Z",
34
+ files: {
35
+ match: "match.json",
36
+ players: "players.json",
37
+ rounds: "rounds.json",
38
+ playerStats: "player-stats.json",
39
+ playerEconomies: "player-economies.json",
40
+ kills: "kills.json",
41
+ damages: "damages.json",
42
+ blinds: "blinds.json",
43
+ bombs: "bombs.json",
44
+ grenades: "grenades.json",
45
+ clutches: "clutches.json"
46
+ }
47
+ },
48
+ match: {
49
+ mapName: "de_mirage",
50
+ tickrate: 64,
51
+ durationSeconds: 120,
52
+ serverName: null,
53
+ source: "test",
54
+ teamA: { teamKey: "teamA", name: "A", score: 1 },
55
+ teamB: { teamKey: "teamB", name: "B", score: 0 }
56
+ },
57
+ players: [
58
+ { steamId64: "76561198000000001", name: "A", teamKey: "teamA" },
59
+ { steamId64: "76561198000000002", name: "B", teamKey: "teamB" }
60
+ ],
61
+ rounds: [{
62
+ roundNumber: 1,
63
+ startTick: 1,
64
+ freezeEndTick: 100,
65
+ endTick: 500,
66
+ winnerTeamKey: "teamA",
67
+ winnerSide: "T",
68
+ teamASide: "T",
69
+ teamBSide: "CT",
70
+ endReason: "target_bombed",
71
+ durationSeconds: 10
72
+ }],
73
+ playerEconomies: [],
74
+ playerStats: [],
75
+ kills: [],
76
+ damages: [],
77
+ blinds: [],
78
+ bombs: [],
79
+ grenades: [],
80
+ clutches: [],
81
+ ...overrides
82
+ } as DemoPackage;
83
+ }
84
+
85
+ describe("buildQaReport", () => {
86
+ it("reports v3 required event families as available when a valid match has zero events", () => {
87
+ const availability = demoSourceAvailability(pkg({ kills: [], damages: [], blinds: [], bombs: [], grenades: [], clutches: [] }));
88
+
89
+ expect(availability).toMatchObject({
90
+ damages: "available",
91
+ bombs: "available",
92
+ utility: "available",
93
+ clutches: "available",
94
+ richKills: "available",
95
+ });
96
+ });
97
+
98
+ it("accepts pre-freeze damage rows but excludes them from active damage analytics", () => {
99
+ const demo = pkg({ damages: [damage(50), damage(120)] });
100
+
101
+ expect(buildQaReport(demo).issues).not.toContainEqual(expect.objectContaining({
102
+ code: "damages.tick_outside_round"
103
+ }));
104
+ expect(activeDamages(demo).map((row) => row.tick)).toEqual([120]);
105
+ });
106
+
107
+ it("still reports damage rows before the round start", () => {
108
+ const demo = pkg({ damages: [damage(0)] });
109
+
110
+ expect(buildQaReport(demo).issues).toContainEqual(expect.objectContaining({
111
+ severity: "error",
112
+ code: "damages.tick_outside_round"
113
+ }));
114
+ });
115
+ });
package/src/qa.ts CHANGED
@@ -3,11 +3,26 @@ import { isNamedWeapon, normalizeWeapon, round } from "./utils.js";
3
3
 
4
4
  export type ScoreboardFieldAvailability = PlayerScoreboardRow["fieldAvailability"];
5
5
 
6
+ export type DemoSourceAvailability = ScoreboardFieldAvailability & {
7
+ utility: "available";
8
+ clutches: "available";
9
+ };
10
+
6
11
  export function buildQaReport(pkg: DemoPackage): QaReport {
7
12
  const issues: QaIssue[] = [];
8
13
  const roundNumbers = pkg.rounds.map((round) => round.roundNumber).sort((a, b) => a - b);
9
14
  const roundsByNumber = new Map(pkg.rounds.map((round) => [round.roundNumber, round]));
10
- const playerIds = new Set(pkg.players.map((player) => player.steamId64));
15
+ // 3.0.3+: 非最终回合的事件窗口延伸到下一回合 startTick 前
16
+ const sortedRounds = [...pkg.rounds].sort((a, b) => a.roundNumber - b.roundNumber);
17
+ const eventEndTickByRound = new Map<number, number>();
18
+ for (let i = 0; i < sortedRounds.length; i++) {
19
+ const current = sortedRounds[i];
20
+ const next = sortedRounds[i + 1];
21
+ eventEndTickByRound.set(
22
+ current.roundNumber,
23
+ next ? next.startTick - 1 : current.endTick
24
+ );
25
+ }
11
26
 
12
27
  for (let i = 0; i < roundNumbers.length; i += 1) {
13
28
  if (roundNumbers[i] !== i + 1) {
@@ -64,7 +79,8 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
64
79
  return;
65
80
  }
66
81
  const minTick = allowFreeze ? roundRow.startTick : roundRow.freezeEndTick;
67
- if (tick < minTick || tick > roundRow.endTick) {
82
+ const eventEnd = eventEndTickByRound.get(roundNumber) ?? roundRow.endTick;
83
+ if (tick < minTick || tick > eventEnd) {
68
84
  issues.push({
69
85
  severity: "error",
70
86
  code: `${kind}.tick_outside_round`,
@@ -86,25 +102,25 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
86
102
 
87
103
  pkg.kills.forEach((kill, index) => {
88
104
  checkEventTick("kills", kill.roundNumber, kill.tick, index);
89
- if (kill.killerSteamId64 && !playerIds.has(kill.killerSteamId64)) {
105
+ if (kill.killerIndex !== null && kill.killerIndex >= pkg.players.length) {
90
106
  issues.push({
91
107
  severity: "warning",
92
108
  code: "kill.unknown_killer",
93
- message: `Killer ${kill.killerSteamId64} is not present in players.json.`,
109
+ message: `Killer playerIndex ${kill.killerIndex} is out of range (players.length=${pkg.players.length}).`,
94
110
  path: "kills"
95
111
  });
96
112
  }
97
- if (!playerIds.has(kill.victimSteamId64)) {
113
+ if (kill.victimIndex >= pkg.players.length) {
98
114
  issues.push({
99
115
  severity: "error",
100
116
  code: "kill.unknown_victim",
101
- message: `Victim ${kill.victimSteamId64} is not present in players.json.`,
117
+ message: `Victim playerIndex ${kill.victimIndex} is out of range (players.length=${pkg.players.length}).`,
102
118
  path: "kills"
103
119
  });
104
120
  }
105
121
  });
106
122
 
107
- pkg.damages.forEach((damage, index) => checkEventTick("damages", damage.roundNumber, damage.tick, index));
123
+ pkg.damages.forEach((damage, index) => checkEventTick("damages", damage.roundNumber, damage.tick, index, true));
108
124
  pkg.blinds.forEach((blind, index) => checkEventTick("blinds", blind.roundNumber, blind.tick, index));
109
125
  pkg.grenades.forEach((grenade, index) => {
110
126
  checkEventTick("grenades", grenade.roundNumber, grenade.throwTick, index);
@@ -117,11 +133,11 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
117
133
  const events = bombEventsByRound.get(bomb.roundNumber) ?? [];
118
134
  events.push(bomb);
119
135
  bombEventsByRound.set(bomb.roundNumber, events);
120
- if (bomb.actorSteamId64 && !playerIds.has(bomb.actorSteamId64)) {
136
+ if (bomb.actorIndex !== null && bomb.actorIndex >= pkg.players.length) {
121
137
  issues.push({
122
138
  severity: "warning",
123
139
  code: "bomb.unknown_actor",
124
- message: `Bomb actor ${bomb.actorSteamId64} is not present in players.json.`,
140
+ message: `Bomb actor playerIndex ${bomb.actorIndex} is out of range (players.length=${pkg.players.length}).`,
125
141
  path: "bombs"
126
142
  });
127
143
  }
@@ -166,13 +182,32 @@ export function buildQaReport(pkg: DemoPackage): QaReport {
166
182
  }
167
183
 
168
184
  export function fieldAvailability(pkg: DemoPackage): ScoreboardFieldAvailability {
185
+ const availability = demoSourceAvailability(pkg);
186
+ return {
187
+ playerStats: availability.playerStats,
188
+ economy: availability.economy,
189
+ rounds: availability.rounds,
190
+ richKills: availability.richKills,
191
+ damages: availability.damages,
192
+ bombs: availability.bombs,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Source capability is determined by the validated v3 contract, never by
198
+ * whether a particular match happened to produce an event. The loader rejects
199
+ * absent required files before this function can run.
200
+ */
201
+ export function demoSourceAvailability(pkg: DemoPackage): DemoSourceAvailability {
169
202
  return {
170
- playerStats: pkg.playerStats.length > 0 ? "available" : "missing",
171
- economy: pkg.playerEconomies.length > 0 ? "available" : "missing",
172
- rounds: pkg.rounds.length > 0 ? "available" : "missing",
203
+ playerStats: "available",
204
+ economy: "available",
205
+ rounds: "available",
173
206
  richKills: richKillAvailability(pkg),
174
- damages: pkg.damages.length > 0 ? "available" : "missing",
175
- bombs: pkg.bombs.length > 0 ? "available" : "missing"
207
+ damages: "available",
208
+ bombs: "available",
209
+ utility: "available",
210
+ clutches: "available",
176
211
  };
177
212
  }
178
213
 
@@ -189,7 +224,8 @@ export function fieldConfidence(availability: ScoreboardFieldAvailability): numb
189
224
  }
190
225
 
191
226
  function richKillAvailability(pkg: DemoPackage): ScoreboardFieldAvailability["richKills"] {
192
- if (pkg.kills.length === 0) return "missing";
227
+ // A legal empty kills.json is an observed zero, not an unavailable source.
228
+ if (pkg.kills.length === 0) return "available";
193
229
  const hasFlags = pkg.kills.some((kill) => "throughSmoke" in kill && "noScope" in kill && "penetratedObjects" in kill);
194
230
  const activeWeaponsAreNames = pkg.kills.some((kill) => kill.killerActiveWeapon && isNamedWeapon(normalizeWeapon(kill.killerActiveWeapon)));
195
231
  if (hasFlags && activeWeaponsAreNames) return "available";
@@ -0,0 +1,79 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+ import { beforeAll, describe, expect, it } from "vitest";
5
+ import { buildRadarFieldGrid } from "@cs2dak/maps";
6
+ import { RADAR_FIELD_BASES, type DemoPackage } from "@cs2dak/contract";
7
+ import { loadDemoPackageFromZip } from "./loader.js";
8
+ import { buildMatchRadarField, aggregateRadarFields } from "./radar-field.js";
9
+
10
+ let pkg: DemoPackage;
11
+
12
+ beforeAll(async () => {
13
+ const zip = await readFile(
14
+ fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
15
+ );
16
+ pkg = await loadDemoPackageFromZip(zip);
17
+ });
18
+
19
+ function radarFieldDigest(fields: ReturnType<typeof buildMatchRadarField>): string {
20
+ const hash = createHash("sha256");
21
+ for (const field of fields) {
22
+ hash.update(field.scope.team ?? "");
23
+ hash.update(Buffer.from(field.denomCt.buffer, field.denomCt.byteOffset, field.denomCt.byteLength));
24
+ hash.update(Buffer.from(field.denomT.buffer, field.denomT.byteOffset, field.denomT.byteLength));
25
+ for (const base of RADAR_FIELD_BASES) {
26
+ for (const row of field.fields[base]) {
27
+ hash.update(Buffer.from(row.buffer, row.byteOffset, row.byteLength));
28
+ }
29
+ }
30
+ }
31
+ return hash.digest("hex");
32
+ }
33
+
34
+ describe("buildMatchRadarField", () => {
35
+ it("每场产出两份按队归属的加性场贡献", () => {
36
+ const grid = buildRadarFieldGrid("de_ancient");
37
+ expect(grid).not.toBeNull();
38
+ const fields = buildMatchRadarField(pkg, { matchId: "m", grid: grid!, bvh: null });
39
+ expect(fields).toHaveLength(2);
40
+
41
+ for (const f of fields) {
42
+ expect(f.scope.kind).toBe("team");
43
+ expect(f.triAvailability).toBe("none"); // 无 .tri
44
+ expect(f.denomCt).toHaveLength(f.maxSec);
45
+ expect(f.denomT).toHaveLength(f.maxSec);
46
+ expect(f.fields.ctVis).toHaveLength(f.maxSec);
47
+ expect(f.fields.ctAim).toHaveLength(f.maxSec);
48
+ expect(f.fields.ctSound).toHaveLength(f.maxSec);
49
+ expect(f.fields.ctVis[0]!).toHaveLength(f.grid.cells.length);
50
+ }
51
+ // 至少有一个回合被采样(denom 有非零),且有视野/位置计数。
52
+ const totalDenom = fields.reduce((s, f) => s + f.denomCt.reduce((a, b) => a + b, 0), 0);
53
+ expect(totalDenom).toBeGreaterThan(0);
54
+ const totalVis = fields.reduce((s, f) => s + f.fields.tPres.reduce((a, row) => a + row.reduce((x, y) => x + y, 0), 0), 0);
55
+ expect(totalVis).toBeGreaterThan(0);
56
+ const totalSound = fields.reduce((s, f) => s + f.fields.tSound.reduce((a, row) => a + row.reduce((x, y) => x + y, 0), 0), 0);
57
+ expect(totalSound).toBeGreaterThan(0);
58
+ });
59
+
60
+ it("sample 输出指纹保持稳定", () => {
61
+ const grid = buildRadarFieldGrid("de_ancient")!;
62
+ const fields = buildMatchRadarField(pkg, { matchId: "snapshot", grid, bvh: null });
63
+ expect(radarFieldDigest(fields)).toBe("0405c90078d54c7ce4a391a1140a9cf2dc6bd3c9a28b4aace3be5d5be3bc8319");
64
+ });
65
+
66
+ it("aggregateRadarFields 是逐元素加法(联赛基线 = 各贡献之和)", () => {
67
+ const grid = buildRadarFieldGrid("de_ancient")!;
68
+ const fields = buildMatchRadarField(pkg, { matchId: "m", grid, bvh: null });
69
+ const league = aggregateRadarFields(fields, { kind: "league", team: null })!;
70
+ expect(league.scope.kind).toBe("league");
71
+
72
+ // 抽查某秒某格:聚合 = 两份之和。
73
+ const s = 20;
74
+ const g = Math.floor(grid.cells.length / 2);
75
+ const expected = fields[0]!.fields.ctVis[s]![g]! + fields[1]!.fields.ctVis[s]![g]!;
76
+ expect(league.fields.ctVis[s]![g]!).toBe(expected);
77
+ expect(league.denomCt[s]!).toBe(fields[0]!.denomCt[s]! + fields[1]!.denomCt[s]!);
78
+ });
79
+ });