@cs2dak/core 0.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +68 -88
- package/src/index.ts +41 -9
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +26 -149
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- package/src/workspace.ts +0 -726
package/src/normalize.ts
CHANGED
|
@@ -1,158 +1,35 @@
|
|
|
1
|
-
import { demoPackageSchema, type DemoPackage
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
171
|
-
economy:
|
|
172
|
-
rounds:
|
|
203
|
+
playerStats: "available",
|
|
204
|
+
economy: "available",
|
|
205
|
+
rounds: "available",
|
|
173
206
|
richKills: richKillAvailability(pkg),
|
|
174
|
-
damages:
|
|
175
|
-
bombs:
|
|
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
|
-
|
|
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
|
+
});
|