@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/timeline.ts
CHANGED
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
import type { DemoPackage, EconomyPoint, HeatmapPoint, TimelineEvent } from "@cs2dak/contract";
|
|
2
|
-
import { round, clamp, formatClock,
|
|
2
|
+
import { round, clamp, formatClock, normalizeWeapon } from "./utils.js";
|
|
3
|
+
import { createResolverFromPackage } from "./resolve.js";
|
|
3
4
|
|
|
4
5
|
export function buildTimeline(pkg: DemoPackage): TimelineEvent[] {
|
|
5
|
-
const
|
|
6
|
-
id: `kill-${index}`,
|
|
7
|
-
roundNumber: kill.roundNumber,
|
|
8
|
-
tick: kill.tick,
|
|
9
|
-
timeSeconds: tickToRoundSeconds(pkg, kill.roundNumber, kill.tick),
|
|
10
|
-
...clockForTick(pkg, kill.roundNumber, kill.tick),
|
|
11
|
-
type: "kill",
|
|
12
|
-
label: `${nameForSteamId(pkg, kill.killerSteamId64) ?? "环境"} 击杀 ${nameForSteamId(pkg, kill.victimSteamId64)}`,
|
|
13
|
-
teamKey: kill.killerTeamKey
|
|
14
|
-
}));
|
|
6
|
+
const resolver = createResolverFromPackage(pkg);
|
|
15
7
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
8
|
+
const killEvents = pkg.kills.map<TimelineEvent>((kill, index) => {
|
|
9
|
+
const killer = resolver.byIndexOrNull(kill.killerIndex);
|
|
10
|
+
const victim = resolver.byIndex(kill.victimIndex);
|
|
11
|
+
return {
|
|
12
|
+
id: `kill-${index}`,
|
|
13
|
+
roundNumber: kill.roundNumber,
|
|
14
|
+
tick: kill.tick,
|
|
15
|
+
timeSeconds: tickToRoundSeconds(pkg, kill.roundNumber, kill.tick),
|
|
16
|
+
...clockForTick(pkg, kill.roundNumber, kill.tick),
|
|
17
|
+
type: "kill",
|
|
18
|
+
label: `${killer?.name ?? "环境"} 击杀 ${victim.name}`,
|
|
19
|
+
teamKey: killer?.teamKey ?? null
|
|
20
|
+
};
|
|
21
|
+
});
|
|
26
22
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
const bombEvents = pkg.bombs.map<TimelineEvent>((bomb, index) => {
|
|
24
|
+
const actor = resolver.byIndexOrNull(bomb.actorIndex);
|
|
25
|
+
return {
|
|
26
|
+
id: `bomb-${index}`,
|
|
27
|
+
roundNumber: bomb.roundNumber,
|
|
28
|
+
tick: bomb.tick,
|
|
29
|
+
timeSeconds: tickToRoundSeconds(pkg, bomb.roundNumber, bomb.tick),
|
|
30
|
+
...clockForTick(pkg, bomb.roundNumber, bomb.tick),
|
|
31
|
+
type: "bomb",
|
|
32
|
+
label: bombLabel(actor?.name, bomb),
|
|
33
|
+
teamKey: actor?.teamKey ?? null
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const grenadeEvents = pkg.grenades.map<TimelineEvent>((grenade, index) => {
|
|
38
|
+
const thrower = resolver.byIndex(grenade.throwerIndex);
|
|
39
|
+
return {
|
|
40
|
+
id: `grenade-${index}`,
|
|
41
|
+
roundNumber: grenade.roundNumber,
|
|
42
|
+
tick: grenade.effectTick,
|
|
43
|
+
timeSeconds: tickToRoundSeconds(pkg, grenade.roundNumber, grenade.effectTick),
|
|
44
|
+
...clockForTick(pkg, grenade.roundNumber, grenade.effectTick),
|
|
45
|
+
type: "grenade",
|
|
46
|
+
label: `${thrower.name} 投掷${grenadeLabel(grenade.grenade)}`,
|
|
47
|
+
teamKey: thrower.teamKey
|
|
48
|
+
};
|
|
49
|
+
});
|
|
37
50
|
|
|
38
51
|
const roundEvents = pkg.rounds.map<TimelineEvent>((roundRow) => ({
|
|
39
52
|
id: `round-end-${roundRow.roundNumber}`,
|
|
@@ -53,11 +66,12 @@ export function buildTimeline(pkg: DemoPackage): TimelineEvent[] {
|
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
export function buildEconomy(pkg: DemoPackage): EconomyPoint[] {
|
|
69
|
+
const resolver = createResolverFromPackage(pkg);
|
|
56
70
|
return pkg.rounds.map((roundRow) => {
|
|
57
71
|
const rows = pkg.playerEconomies.filter((row) => row.roundNumber === roundRow.roundNumber);
|
|
58
72
|
const sumForTeam = (teamKey: "teamA" | "teamB") =>
|
|
59
73
|
rows
|
|
60
|
-
.filter((row) => row.teamKey === teamKey)
|
|
74
|
+
.filter((row) => resolver.byIndexOrNull(row.playerIndex)?.teamKey === teamKey)
|
|
61
75
|
.reduce((sum, row) => sum + row.equipmentValue, 0);
|
|
62
76
|
const teamA = sumForTeam("teamA");
|
|
63
77
|
const teamB = sumForTeam("teamB");
|
|
@@ -75,7 +89,7 @@ export function buildEconomy(pkg: DemoPackage): EconomyPoint[] {
|
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
export function buildHeatmap(pkg: DemoPackage): HeatmapPoint[] {
|
|
78
|
-
|
|
92
|
+
const resolver = createResolverFromPackage(pkg);
|
|
79
93
|
const roundSides = new Map(
|
|
80
94
|
pkg.rounds.map((r) => [r.roundNumber, { teamA: r.teamASide, teamB: r.teamBSide }])
|
|
81
95
|
);
|
|
@@ -87,15 +101,19 @@ export function buildHeatmap(pkg: DemoPackage): HeatmapPoint[] {
|
|
|
87
101
|
};
|
|
88
102
|
|
|
89
103
|
const kills = pkg.kills.flatMap<HeatmapPoint>((kill) => {
|
|
104
|
+
const victimPlayer = resolver.byIndexOrNull(kill.victimIndex);
|
|
105
|
+
const killerPlayer = resolver.byIndexOrNull(kill.killerIndex);
|
|
106
|
+
const victimTeamKey = victimPlayer?.teamKey ?? null;
|
|
107
|
+
const killerTeamKey = killerPlayer?.teamKey ?? null;
|
|
90
108
|
const out: HeatmapPoint[] = [
|
|
91
109
|
{
|
|
92
110
|
x: kill.victimPosition.x,
|
|
93
111
|
y: kill.victimPosition.y,
|
|
94
112
|
z: kill.victimPosition.z,
|
|
95
113
|
roundNumber: kill.roundNumber,
|
|
96
|
-
teamKey:
|
|
97
|
-
steamId64:
|
|
98
|
-
side: sideFor(
|
|
114
|
+
teamKey: victimTeamKey,
|
|
115
|
+
steamId64: victimPlayer?.steamId64 ?? null,
|
|
116
|
+
side: sideFor(victimTeamKey, kill.roundNumber),
|
|
99
117
|
kind: "death",
|
|
100
118
|
grenadeType: null
|
|
101
119
|
}
|
|
@@ -106,9 +124,9 @@ export function buildHeatmap(pkg: DemoPackage): HeatmapPoint[] {
|
|
|
106
124
|
y: kill.killerPosition.y,
|
|
107
125
|
z: kill.killerPosition.z,
|
|
108
126
|
roundNumber: kill.roundNumber,
|
|
109
|
-
teamKey:
|
|
110
|
-
steamId64:
|
|
111
|
-
side: sideFor(
|
|
127
|
+
teamKey: killerTeamKey,
|
|
128
|
+
steamId64: killerPlayer?.steamId64 ?? null,
|
|
129
|
+
side: sideFor(killerTeamKey, kill.roundNumber),
|
|
112
130
|
kind: "kill",
|
|
113
131
|
grenadeType: null
|
|
114
132
|
});
|
|
@@ -117,17 +135,20 @@ export function buildHeatmap(pkg: DemoPackage): HeatmapPoint[] {
|
|
|
117
135
|
});
|
|
118
136
|
|
|
119
137
|
const grenades = pkg.grenades
|
|
120
|
-
.map<HeatmapPoint>((grenade) =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
.map<HeatmapPoint>((grenade) => {
|
|
139
|
+
const thrower = resolver.byIndexOrNull(grenade.throwerIndex);
|
|
140
|
+
return {
|
|
141
|
+
x: grenade.effectPosition.x,
|
|
142
|
+
y: grenade.effectPosition.y,
|
|
143
|
+
z: grenade.effectPosition.z,
|
|
144
|
+
roundNumber: grenade.roundNumber,
|
|
145
|
+
teamKey: thrower?.teamKey ?? null,
|
|
146
|
+
steamId64: thrower?.steamId64 ?? null,
|
|
147
|
+
side: sideFor(thrower?.teamKey ?? null, grenade.roundNumber),
|
|
148
|
+
kind: "grenade",
|
|
149
|
+
grenadeType: grenade.grenade
|
|
150
|
+
};
|
|
151
|
+
});
|
|
131
152
|
|
|
132
153
|
return [...kills, ...grenades].filter((point) => point.x !== 0 || point.y !== 0);
|
|
133
154
|
}
|
|
@@ -166,8 +187,8 @@ function eventSortWeight(type: TimelineEvent["type"]): number {
|
|
|
166
187
|
return 3;
|
|
167
188
|
}
|
|
168
189
|
|
|
169
|
-
function bombLabel(
|
|
170
|
-
const actor =
|
|
190
|
+
function bombLabel(actorName: string | undefined, bomb: DemoPackage["bombs"][number]): string {
|
|
191
|
+
const actor = actorName ?? "未知选手";
|
|
171
192
|
const labels: Record<string, string> = {
|
|
172
193
|
planted: "下包",
|
|
173
194
|
defused: "拆包",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { loadDemoPackageFromZip } from "./loader.js";
|
|
5
|
+
import { buildPlayerRoundFacts } from "./scoreboard.js";
|
|
6
|
+
import { buildPlayerRoundUtilityFacts } from "./utility-facts.js";
|
|
7
|
+
|
|
8
|
+
const fixture = async () => loadDemoPackageFromZip(await readFile(fileURLToPath(
|
|
9
|
+
new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)
|
|
10
|
+
)));
|
|
11
|
+
|
|
12
|
+
describe("buildPlayerRoundUtilityFacts", () => {
|
|
13
|
+
it("owns the per-round utility values consumed by PlayerRoundFact", async () => {
|
|
14
|
+
const pkg = await fixture();
|
|
15
|
+
const utilityFacts = buildPlayerRoundUtilityFacts(pkg);
|
|
16
|
+
const playerRounds = buildPlayerRoundFacts(pkg);
|
|
17
|
+
|
|
18
|
+
expect(utilityFacts).toHaveLength(pkg.rounds.length * pkg.players.length);
|
|
19
|
+
const byKey = new Map(utilityFacts.map((fact) => [`${fact.roundNumber}:${fact.steamId64}`, fact]));
|
|
20
|
+
for (const playerRound of playerRounds) {
|
|
21
|
+
const utility = byKey.get(`${playerRound.roundNumber}:${playerRound.steamId64}`);
|
|
22
|
+
expect(utility).toBeDefined();
|
|
23
|
+
expect(playerRound.utilityDamage).toBe(utility?.utilityDamage);
|
|
24
|
+
expect(playerRound.flashAssists).toBe(utility?.flashAssists);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const stats of pkg.playerStats) {
|
|
28
|
+
const steamId64 = pkg.players[stats.playerIndex]!.steamId64;
|
|
29
|
+
const totals = utilityFacts
|
|
30
|
+
.filter((fact) => fact.steamId64 === steamId64)
|
|
31
|
+
.reduce((sum, fact) => ({
|
|
32
|
+
enemyBlindSeconds: sum.enemyBlindSeconds + fact.enemyBlindSeconds,
|
|
33
|
+
teamBlindSeconds: sum.teamBlindSeconds + fact.teamBlindSeconds,
|
|
34
|
+
flashAssists: sum.flashAssists + fact.flashAssists,
|
|
35
|
+
}), { enemyBlindSeconds: 0, teamBlindSeconds: 0, flashAssists: 0 });
|
|
36
|
+
expect(totals.enemyBlindSeconds).toBeCloseTo(stats.enemyFlashDurationSeconds, 3);
|
|
37
|
+
expect(totals.teamBlindSeconds).toBeCloseTo(stats.teamFlashDurationSeconds, 3);
|
|
38
|
+
expect(totals.flashAssists).toBe(stats.flashAssistCount);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses the same active round window as core damage facts", async () => {
|
|
43
|
+
const pkg = await fixture();
|
|
44
|
+
const firstRound = pkg.rounds[0]!;
|
|
45
|
+
const sourceDamage = pkg.damages[0]!;
|
|
46
|
+
const withFreezeDamage = {
|
|
47
|
+
...pkg,
|
|
48
|
+
damages: [...pkg.damages, { ...sourceDamage, roundNumber: firstRound.roundNumber, tick: firstRound.freezeEndTick - 1 }],
|
|
49
|
+
};
|
|
50
|
+
const baseline = buildPlayerRoundUtilityFacts(pkg);
|
|
51
|
+
const actual = buildPlayerRoundUtilityFacts(withFreezeDamage);
|
|
52
|
+
|
|
53
|
+
expect(actual).toEqual(baseline);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
2
|
+
import { activeDamages, isUtilityWeapon, normalizeWeapon } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 每位选手、每回合的低维道具 sufficient facts。
|
|
6
|
+
*
|
|
7
|
+
* 这是所有 DAK consumer(包括 Studio 与产品 adapter)对 flash / HE / fire /
|
|
8
|
+
* smoke / utility kill 的唯一事件归因口径;只读取已验证的 v3 DemoPackage,
|
|
9
|
+
* 不包含空间或逐事件流。
|
|
10
|
+
*/
|
|
11
|
+
export type PlayerRoundUtilityFact = {
|
|
12
|
+
roundNumber: number;
|
|
13
|
+
steamId64: string;
|
|
14
|
+
flashesThrown: number;
|
|
15
|
+
enemyBlindSeconds: number;
|
|
16
|
+
teamBlindSeconds: number;
|
|
17
|
+
/** Enemy blind person-events, matching playerStats/presentation semantics. */
|
|
18
|
+
enemyBlindVictims: number;
|
|
19
|
+
/** Flash-assist credits assigned to the flash assister, not the killer. */
|
|
20
|
+
flashAssists: number;
|
|
21
|
+
heThrows: number;
|
|
22
|
+
heDamage: number;
|
|
23
|
+
fireThrows: number;
|
|
24
|
+
fireDamage: number;
|
|
25
|
+
smokesThrown: number;
|
|
26
|
+
utilityKills: number;
|
|
27
|
+
utilityDamage: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type MutableUtilityFact = Omit<PlayerRoundUtilityFact, "roundNumber" | "steamId64">;
|
|
31
|
+
|
|
32
|
+
function isFireWeapon(weapon: string): boolean {
|
|
33
|
+
return ["inferno", "molotov", "incgrenade", "incendiary"].includes(normalizeWeapon(weapon));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emptyFact(): MutableUtilityFact {
|
|
37
|
+
return {
|
|
38
|
+
flashesThrown: 0,
|
|
39
|
+
enemyBlindSeconds: 0,
|
|
40
|
+
teamBlindSeconds: 0,
|
|
41
|
+
enemyBlindVictims: 0,
|
|
42
|
+
flashAssists: 0,
|
|
43
|
+
heThrows: 0,
|
|
44
|
+
heDamage: 0,
|
|
45
|
+
fireThrows: 0,
|
|
46
|
+
fireDamage: 0,
|
|
47
|
+
smokesThrown: 0,
|
|
48
|
+
utilityKills: 0,
|
|
49
|
+
utilityDamage: 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Returns one row for every known player and round, including real zeroes. */
|
|
54
|
+
export function buildPlayerRoundUtilityFacts(pkg: DemoPackage): PlayerRoundUtilityFact[] {
|
|
55
|
+
const rows = new Map<string, MutableUtilityFact>();
|
|
56
|
+
const damages = activeDamages(pkg);
|
|
57
|
+
const keyFor = (roundNumber: number, playerIndex: number) => `${roundNumber}:${playerIndex}`;
|
|
58
|
+
const factFor = (roundNumber: number, playerIndex: number): MutableUtilityFact | null => {
|
|
59
|
+
if (!pkg.players[playerIndex]) return null;
|
|
60
|
+
const key = keyFor(roundNumber, playerIndex);
|
|
61
|
+
const existing = rows.get(key);
|
|
62
|
+
if (existing) return existing;
|
|
63
|
+
const created = emptyFact();
|
|
64
|
+
rows.set(key, created);
|
|
65
|
+
return created;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const round of pkg.rounds) {
|
|
69
|
+
for (let playerIndex = 0; playerIndex < pkg.players.length; playerIndex += 1) factFor(round.roundNumber, playerIndex);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const grenade of pkg.grenades) {
|
|
73
|
+
const fact = factFor(grenade.roundNumber, grenade.throwerIndex);
|
|
74
|
+
if (!fact) continue;
|
|
75
|
+
if (grenade.grenade === "flashbang") fact.flashesThrown += 1;
|
|
76
|
+
else if (grenade.grenade === "hegrenade") fact.heThrows += 1;
|
|
77
|
+
else if (grenade.grenade === "molotov" || grenade.grenade === "incendiary") fact.fireThrows += 1;
|
|
78
|
+
else if (grenade.grenade === "smoke") fact.smokesThrown += 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const blind of pkg.blinds) {
|
|
82
|
+
const flasher = pkg.players[blind.flasherIndex];
|
|
83
|
+
const flashed = pkg.players[blind.flashedIndex];
|
|
84
|
+
const fact = factFor(blind.roundNumber, blind.flasherIndex);
|
|
85
|
+
if (!flasher || !flashed || !fact) continue;
|
|
86
|
+
if (flasher.teamKey !== flashed.teamKey) {
|
|
87
|
+
fact.enemyBlindSeconds += blind.durationSeconds;
|
|
88
|
+
// This is the existing DAK presentation meaning: blinded enemy
|
|
89
|
+
// person-events, not a de-duplicated flash/victim identity.
|
|
90
|
+
fact.enemyBlindVictims += 1;
|
|
91
|
+
} else {
|
|
92
|
+
fact.teamBlindSeconds += blind.durationSeconds;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const damage of damages) {
|
|
97
|
+
if (damage.attackerIndex === null) continue;
|
|
98
|
+
const attacker = pkg.players[damage.attackerIndex];
|
|
99
|
+
const victim = pkg.players[damage.victimIndex];
|
|
100
|
+
const fact = factFor(damage.roundNumber, damage.attackerIndex);
|
|
101
|
+
if (!attacker || !victim || !fact || attacker.teamKey === victim.teamKey) continue;
|
|
102
|
+
if (normalizeWeapon(damage.weapon) === "hegrenade") fact.heDamage += damage.healthDamage;
|
|
103
|
+
if (isFireWeapon(damage.weapon)) fact.fireDamage += damage.healthDamage;
|
|
104
|
+
if (isUtilityWeapon(damage.weapon)) fact.utilityDamage += damage.healthDamage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const kill of pkg.kills) {
|
|
108
|
+
if (kill.killerIndex !== null) {
|
|
109
|
+
const killerFact = factFor(kill.roundNumber, kill.killerIndex);
|
|
110
|
+
if (killerFact && isUtilityWeapon(kill.weapon)) killerFact.utilityKills += 1;
|
|
111
|
+
}
|
|
112
|
+
if (kill.flashAssist && kill.flashAssisterIndex !== null) {
|
|
113
|
+
const assisterFact = factFor(kill.roundNumber, kill.flashAssisterIndex);
|
|
114
|
+
if (assisterFact) assisterFact.flashAssists += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return pkg.rounds.flatMap((round) => pkg.players.map((player, playerIndex) => {
|
|
119
|
+
const fact = rows.get(keyFor(round.roundNumber, playerIndex)) ?? emptyFact();
|
|
120
|
+
return {
|
|
121
|
+
roundNumber: round.roundNumber,
|
|
122
|
+
steamId64: player.steamId64,
|
|
123
|
+
flashesThrown: fact.flashesThrown,
|
|
124
|
+
enemyBlindSeconds: fact.enemyBlindSeconds,
|
|
125
|
+
teamBlindSeconds: fact.teamBlindSeconds,
|
|
126
|
+
enemyBlindVictims: fact.enemyBlindVictims,
|
|
127
|
+
flashAssists: fact.flashAssists,
|
|
128
|
+
heThrows: fact.heThrows,
|
|
129
|
+
heDamage: fact.heDamage,
|
|
130
|
+
fireThrows: fact.fireThrows,
|
|
131
|
+
fireDamage: fact.fireDamage,
|
|
132
|
+
smokesThrown: fact.smokesThrown,
|
|
133
|
+
utilityKills: fact.utilityKills,
|
|
134
|
+
utilityDamage: fact.utilityDamage,
|
|
135
|
+
};
|
|
136
|
+
}));
|
|
137
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DemoPackage, RRSignals } from "@cs2dak/contract";
|
|
2
|
+
import { createResolverFromPackage } from "./resolve.js";
|
|
2
3
|
|
|
3
|
-
export type BuyDeltaBuckets = NonNullable<
|
|
4
|
-
export type ManStateBuckets = NonNullable<
|
|
5
|
-
export type ObjectiveBuckets =
|
|
6
|
-
export type UtilityBuckets =
|
|
4
|
+
export type BuyDeltaBuckets = NonNullable<RRSignals["combat"]["killsByBuyDelta"]>;
|
|
5
|
+
export type ManStateBuckets = NonNullable<RRSignals["combat"]["killsByManState"]>;
|
|
6
|
+
export type ObjectiveBuckets = RRSignals["objective"];
|
|
7
|
+
export type UtilityBuckets = RRSignals["utility"];
|
|
7
8
|
|
|
8
9
|
export const BUY_DELTA_EVEN_THRESHOLD = 1000;
|
|
9
10
|
|
|
@@ -60,18 +61,32 @@ export function firstKillMap(pkg: DemoPackage): Map<number, DemoPackage["kills"]
|
|
|
60
61
|
return firstKillByRound;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
export function
|
|
64
|
-
return pkg.damages
|
|
65
|
-
|
|
64
|
+
export function activeDamages(pkg: DemoPackage): DemoPackage["damages"] {
|
|
65
|
+
if (pkg.rounds.length === 0) return pkg.damages;
|
|
66
|
+
const roundsByNumber = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
|
|
67
|
+
return pkg.damages.filter((damage) => {
|
|
68
|
+
const roundRow = roundsByNumber.get(damage.roundNumber);
|
|
69
|
+
return Boolean(roundRow && damage.tick >= roundRow.freezeEndTick && damage.tick <= roundRow.endTick);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sumDamageForPlayer(pkg: DemoPackage, playerIndex: number): number {
|
|
74
|
+
const resolver = createResolverFromPackage(pkg);
|
|
75
|
+
const playerTeam = resolver.byIndex(playerIndex).teamKey;
|
|
76
|
+
return activeDamages(pkg)
|
|
77
|
+
.filter((damage) =>
|
|
78
|
+
damage.attackerIndex === playerIndex &&
|
|
79
|
+
resolver.byIndexOrNull(damage.victimIndex)?.teamKey !== playerTeam
|
|
80
|
+
)
|
|
66
81
|
.reduce((sum, damage) => sum + damage.healthDamage, 0);
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
export function openingKillsForPlayer(pkg: DemoPackage,
|
|
70
|
-
return [...firstKillMap(pkg).values()].filter((kill) => kill.
|
|
84
|
+
export function openingKillsForPlayer(pkg: DemoPackage, playerIndex: number): number {
|
|
85
|
+
return [...firstKillMap(pkg).values()].filter((kill) => kill.killerIndex === playerIndex).length;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
export function openingDeathsForPlayer(pkg: DemoPackage,
|
|
74
|
-
return [...firstKillMap(pkg).values()].filter((kill) => kill.
|
|
88
|
+
export function openingDeathsForPlayer(pkg: DemoPackage, playerIndex: number): number {
|
|
89
|
+
return [...firstKillMap(pkg).values()].filter((kill) => kill.victimIndex === playerIndex).length;
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
export function multiKillRounds(kills: DemoPackage["kills"], target: number): number {
|
|
@@ -83,19 +98,6 @@ export function multiKillRounds(kills: DemoPackage["kills"], target: number): nu
|
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
export function clutchSplit(
|
|
86
|
-
statsCount: number | undefined,
|
|
87
|
-
statsWon: number | undefined,
|
|
88
|
-
clutches: DemoPackage["clutches"],
|
|
89
|
-
opponentCount: number
|
|
90
|
-
) {
|
|
91
|
-
const rows = clutches.filter((row) => row.opponentCount === opponentCount);
|
|
92
|
-
return {
|
|
93
|
-
count: statsCount ?? rows.length,
|
|
94
|
-
won: statsWon ?? rows.filter((row) => row.won).length
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function clutchSplitV2(
|
|
99
101
|
count: number | undefined,
|
|
100
102
|
won: number | undefined,
|
|
101
103
|
rows: DemoPackage["clutches"],
|
package/src/weapon-highlights.ts
CHANGED
|
@@ -11,11 +11,11 @@ export function derivePlayerWeaponHighlights(input: unknown): PlayerWeaponHighli
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function buildPlayerWeaponHighlights(pkg: DemoPackage): PlayerWeaponHighlightFacts[] {
|
|
14
|
-
const
|
|
14
|
+
const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
|
|
15
15
|
|
|
16
|
-
return pkg.players.map((player) => {
|
|
17
|
-
const stats =
|
|
18
|
-
const kills = pkg.kills.filter((kill) => kill.
|
|
16
|
+
return pkg.players.map((player, playerIdx) => {
|
|
17
|
+
const stats = statsMap.get(playerIdx);
|
|
18
|
+
const kills = pkg.kills.filter((kill) => kill.killerIndex === playerIdx);
|
|
19
19
|
const weaponCounts = new Map<string, PlayerWeaponHighlightFacts["weapons"][number]>();
|
|
20
20
|
for (const kill of kills) {
|
|
21
21
|
const weapon = killWeaponName(kill);
|
package/src/economy.test.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { EconomyPoint } from "@cs2dak/contract";
|
|
3
|
-
import { buildEconomyConversion } from "./economy";
|
|
4
|
-
|
|
5
|
-
function pt(p: Partial<EconomyPoint> & Pick<EconomyPoint, "teamAEconomy" | "teamBEconomy" | "winnerTeamKey">): EconomyPoint {
|
|
6
|
-
return {
|
|
7
|
-
roundNumber: 1,
|
|
8
|
-
teamA: 0,
|
|
9
|
-
teamB: 0,
|
|
10
|
-
advantage: 0,
|
|
11
|
-
...p,
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe("buildEconomyConversion", () => {
|
|
16
|
-
it("aggregates per-team win rate by economy type", () => {
|
|
17
|
-
const points: EconomyPoint[] = [
|
|
18
|
-
pt({ teamAEconomy: "full", teamBEconomy: "eco", winnerTeamKey: "teamA" }),
|
|
19
|
-
pt({ teamAEconomy: "full", teamBEconomy: "full", winnerTeamKey: "teamB" }),
|
|
20
|
-
pt({ teamAEconomy: "eco", teamBEconomy: "full", winnerTeamKey: "teamA" }),
|
|
21
|
-
];
|
|
22
|
-
const { teamA, teamB } = buildEconomyConversion(points);
|
|
23
|
-
|
|
24
|
-
// teamA played full twice, won once.
|
|
25
|
-
expect(teamA.full).toEqual({ played: 2, won: 1, winRate: 0.5 });
|
|
26
|
-
// teamA played eco once, won it (an eco upset).
|
|
27
|
-
expect(teamA.eco).toEqual({ played: 1, won: 1, winRate: 1 });
|
|
28
|
-
// teamB played full twice, won once.
|
|
29
|
-
expect(teamB.full).toEqual({ played: 2, won: 1, winRate: 0.5 });
|
|
30
|
-
// teamB played eco once, lost it.
|
|
31
|
-
expect(teamB.eco).toEqual({ played: 1, won: 0, winRate: 0 });
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("returns empty conversions for no rounds", () => {
|
|
35
|
-
expect(buildEconomyConversion([])).toEqual({ teamA: {}, teamB: {} });
|
|
36
|
-
});
|
|
37
|
-
});
|
package/src/economy.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { EconomyPoint, TeamEconomyType } from "@cs2dak/contract";
|
|
2
|
-
|
|
3
|
-
/** 单一经济类型下的回合胜负统计。 */
|
|
4
|
-
export interface EconomyTypeStats {
|
|
5
|
-
played: number;
|
|
6
|
-
won: number;
|
|
7
|
-
winRate: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** 某队按经济类型分组的转化率,键为经济类型。 */
|
|
11
|
-
export type EconomyConversion = Partial<Record<TeamEconomyType, EconomyTypeStats>>;
|
|
12
|
-
|
|
13
|
-
/** 一场比赛两队的经济转化率。 */
|
|
14
|
-
export interface MatchEconomyConversion {
|
|
15
|
-
teamA: EconomyConversion;
|
|
16
|
-
teamB: EconomyConversion;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function tally(
|
|
20
|
-
acc: Map<string, { played: number; won: number }>,
|
|
21
|
-
type: TeamEconomyType,
|
|
22
|
-
won: boolean,
|
|
23
|
-
): void {
|
|
24
|
-
const g = acc.get(type) ?? { played: 0, won: 0 };
|
|
25
|
-
g.played += 1;
|
|
26
|
-
if (won) g.won += 1;
|
|
27
|
-
acc.set(type, g);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function finalize(acc: Map<string, { played: number; won: number }>): EconomyConversion {
|
|
31
|
-
const out: EconomyConversion = {};
|
|
32
|
-
for (const [type, s] of acc) {
|
|
33
|
-
out[type as TeamEconomyType] = {
|
|
34
|
-
played: s.played,
|
|
35
|
-
won: s.won,
|
|
36
|
-
winRate: s.played > 0 ? s.won / s.played : 0,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* 经济转化率:按经济类型统计每队的回合胜率。
|
|
44
|
-
*
|
|
45
|
-
* 直接从 kit 的 `EconomyPoint[]` 派生——每个点已含两队经济类型 + 胜方,
|
|
46
|
-
* 无需另一套松散输入。统一并取代 RivalHub `economy-conversion.ts` 的等价逻辑
|
|
47
|
-
* (那边按队拆开传入,这里一次产出两队)。纯函数,无副作用。
|
|
48
|
-
*/
|
|
49
|
-
export function buildEconomyConversion(points: EconomyPoint[]): MatchEconomyConversion {
|
|
50
|
-
const teamA = new Map<string, { played: number; won: number }>();
|
|
51
|
-
const teamB = new Map<string, { played: number; won: number }>();
|
|
52
|
-
for (const p of points) {
|
|
53
|
-
tally(teamA, p.teamAEconomy, p.winnerTeamKey === "teamA");
|
|
54
|
-
tally(teamB, p.teamBEconomy, p.winnerTeamKey === "teamB");
|
|
55
|
-
}
|
|
56
|
-
return { teamA: finalize(teamA), teamB: finalize(teamB) };
|
|
57
|
-
}
|