@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/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
|
+
});
|