@cs2dak/core 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +46 -53
  8. package/src/index.ts +25 -8
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +27 -1
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/signals.ts +149 -112
  35. package/src/spatial/annotate.test.ts +133 -0
  36. package/src/spatial/annotate.ts +163 -0
  37. package/src/spatial/index.ts +16 -0
  38. package/src/spatial/mapcontrol.test.ts +131 -0
  39. package/src/spatial/mapcontrol.ts +277 -0
  40. package/src/spatial/phase.test.ts +171 -0
  41. package/src/spatial/phase.ts +179 -0
  42. package/src/spatial/trade-closure.test.ts +38 -0
  43. package/src/spatial/types.ts +56 -0
  44. package/src/spatial/utility-geometry.test.ts +88 -0
  45. package/src/spatial/utility-geometry.ts +167 -0
  46. package/src/spatial/utility.integration.test.ts +38 -0
  47. package/src/spatial/utility.test.ts +120 -0
  48. package/src/spatial/utility.ts +399 -0
  49. package/src/tactics/formations.ts +151 -0
  50. package/src/tactics/index.ts +16 -0
  51. package/src/tactics/replay-round-context.ts +96 -0
  52. package/src/tactics/round-facts.ts +406 -0
  53. package/src/tactics/segments.ts +68 -0
  54. package/src/tactics/tactics.test.ts +112 -0
  55. package/src/tactics/types.ts +73 -0
  56. package/src/timeline.ts +73 -52
  57. package/src/utility-facts.test.ts +55 -0
  58. package/src/utility-facts.ts +137 -0
  59. package/src/utils.ts +27 -25
  60. package/src/weapon-highlights.ts +4 -4
  61. package/src/economy.test.ts +0 -37
  62. package/src/economy.ts +0 -57
package/src/timeline.ts CHANGED
@@ -1,39 +1,52 @@
1
1
  import type { DemoPackage, EconomyPoint, HeatmapPoint, TimelineEvent } from "@cs2dak/contract";
2
- import { round, clamp, formatClock, nameForSteamId, normalizeWeapon } from "./utils.js";
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 killEvents = pkg.kills.map<TimelineEvent>((kill, index) => ({
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 bombEvents = pkg.bombs.map<TimelineEvent>((bomb, index) => ({
17
- id: `bomb-${index}`,
18
- roundNumber: bomb.roundNumber,
19
- tick: bomb.tick,
20
- timeSeconds: tickToRoundSeconds(pkg, bomb.roundNumber, bomb.tick),
21
- ...clockForTick(pkg, bomb.roundNumber, bomb.tick),
22
- type: "bomb",
23
- label: bombLabel(pkg, bomb),
24
- teamKey: bomb.actorTeamKey
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 grenadeEvents = pkg.grenades.map<TimelineEvent>((grenade, index) => ({
28
- id: `grenade-${index}`,
29
- roundNumber: grenade.roundNumber,
30
- tick: grenade.effectTick,
31
- timeSeconds: tickToRoundSeconds(pkg, grenade.roundNumber, grenade.effectTick),
32
- ...clockForTick(pkg, grenade.roundNumber, grenade.effectTick),
33
- type: "grenade",
34
- label: `${nameForSteamId(pkg, grenade.throwerSteamId64) ?? "未知选手"} 投掷${grenadeLabel(grenade.grenade)}`,
35
- teamKey: grenade.throwerTeamKey
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
- // Pre-index sides per round to derive CT/T for any teamKey lookup.
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: kill.victimTeamKey,
97
- steamId64: kill.victimSteamId64,
98
- side: sideFor(kill.victimTeamKey, kill.roundNumber),
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: kill.killerTeamKey,
110
- steamId64: kill.killerSteamId64,
111
- side: sideFor(kill.killerTeamKey, kill.roundNumber),
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
- x: grenade.effectPosition.x,
122
- y: grenade.effectPosition.y,
123
- z: grenade.effectPosition.z,
124
- roundNumber: grenade.roundNumber,
125
- teamKey: grenade.throwerTeamKey,
126
- steamId64: grenade.throwerSteamId64,
127
- side: sideFor(grenade.throwerTeamKey, grenade.roundNumber),
128
- kind: "grenade",
129
- grenadeType: grenade.grenade
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(pkg: DemoPackage, bomb: DemoPackage["bombs"][number]): string {
170
- const actor = nameForSteamId(pkg, bomb.actorSteamId64) ?? "未知选手";
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 { AccountSignalsV2, DemoPackage } from "@cs2dak/contract";
1
+ import type { DemoPackage, RRSignals } from "@cs2dak/contract";
2
+ import { createResolverFromPackage } from "./resolve.js";
2
3
 
3
- export type BuyDeltaBuckets = NonNullable<AccountSignalsV2["combat"]["killsByBuyDelta"]>;
4
- export type ManStateBuckets = NonNullable<AccountSignalsV2["combat"]["killsByManState"]>;
5
- export type ObjectiveBuckets = AccountSignalsV2["objective"];
6
- export type UtilityBuckets = AccountSignalsV2["utility"];
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 sumDamageForPlayer(pkg: DemoPackage, steamId64: string): number {
64
- return pkg.damages
65
- .filter((damage) => damage.attackerSteamId64 === steamId64 && damage.attackerTeamKey !== damage.victimTeamKey)
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, steamId64: string): number {
70
- return [...firstKillMap(pkg).values()].filter((kill) => kill.killerSteamId64 === steamId64).length;
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, steamId64: string): number {
74
- return [...firstKillMap(pkg).values()].filter((kill) => kill.victimSteamId64 === steamId64).length;
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"],
@@ -11,11 +11,11 @@ export function derivePlayerWeaponHighlights(input: unknown): PlayerWeaponHighli
11
11
  }
12
12
 
13
13
  export function buildPlayerWeaponHighlights(pkg: DemoPackage): PlayerWeaponHighlightFacts[] {
14
- const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
14
+ const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
15
15
 
16
- return pkg.players.map((player) => {
17
- const stats = statsBySteamId.get(player.steamId64);
18
- const kills = pkg.kills.filter((kill) => kill.killerSteamId64 === player.steamId64);
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);
@@ -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
- }