@cs2dak/core 0.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +68 -88
  8. package/src/index.ts +41 -9
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +26 -149
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/side-win-rate.test.ts +33 -0
  35. package/src/side-win-rate.ts +49 -0
  36. package/src/signals.ts +150 -113
  37. package/src/spatial/annotate.test.ts +133 -0
  38. package/src/spatial/annotate.ts +163 -0
  39. package/src/spatial/index.ts +16 -0
  40. package/src/spatial/mapcontrol.test.ts +131 -0
  41. package/src/spatial/mapcontrol.ts +277 -0
  42. package/src/spatial/phase.test.ts +171 -0
  43. package/src/spatial/phase.ts +179 -0
  44. package/src/spatial/trade-closure.test.ts +38 -0
  45. package/src/spatial/types.ts +56 -0
  46. package/src/spatial/utility-geometry.test.ts +88 -0
  47. package/src/spatial/utility-geometry.ts +167 -0
  48. package/src/spatial/utility.integration.test.ts +38 -0
  49. package/src/spatial/utility.test.ts +120 -0
  50. package/src/spatial/utility.ts +399 -0
  51. package/src/tactics/formations.ts +151 -0
  52. package/src/tactics/index.ts +16 -0
  53. package/src/tactics/replay-round-context.ts +96 -0
  54. package/src/tactics/round-facts.ts +406 -0
  55. package/src/tactics/segments.ts +68 -0
  56. package/src/tactics/tactics.test.ts +112 -0
  57. package/src/tactics/types.ts +73 -0
  58. package/src/timeline.ts +73 -52
  59. package/src/utility-facts.test.ts +55 -0
  60. package/src/utility-facts.ts +137 -0
  61. package/src/utils.ts +27 -25
  62. package/src/weapon-highlights.ts +55 -0
  63. package/src/economy.test.ts +0 -51
  64. package/src/economy.ts +0 -76
  65. package/src/weapons.test.ts +0 -32
  66. package/src/weapons.ts +0 -93
  67. package/src/workspace.ts +0 -726
package/src/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"],
@@ -0,0 +1,55 @@
1
+ import {
2
+ playerWeaponHighlightFactsSchema,
3
+ type DemoPackage,
4
+ type PlayerWeaponHighlightFacts
5
+ } from "@cs2dak/contract";
6
+ import { normalizeDemoPackage } from "./normalize.js";
7
+ import { killWeaponName } from "./utils.js";
8
+
9
+ export function derivePlayerWeaponHighlights(input: unknown): PlayerWeaponHighlightFacts[] {
10
+ return buildPlayerWeaponHighlights(normalizeDemoPackage(input));
11
+ }
12
+
13
+ export function buildPlayerWeaponHighlights(pkg: DemoPackage): PlayerWeaponHighlightFacts[] {
14
+ const statsMap = new Map(pkg.playerStats.map((row) => [row.playerIndex, row]));
15
+
16
+ return pkg.players.map((player, playerIdx) => {
17
+ const stats = statsMap.get(playerIdx);
18
+ const kills = pkg.kills.filter((kill) => kill.killerIndex === playerIdx);
19
+ const weaponCounts = new Map<string, PlayerWeaponHighlightFacts["weapons"][number]>();
20
+ for (const kill of kills) {
21
+ const weapon = killWeaponName(kill);
22
+ const row = weaponCounts.get(weapon) ?? {
23
+ weapon,
24
+ kills: 0,
25
+ headshotKills: 0,
26
+ tradeKills: 0,
27
+ noScopeKills: 0,
28
+ throughSmokeKills: 0,
29
+ wallbangKills: 0,
30
+ penetratedObjects: 0
31
+ };
32
+ row.kills += 1;
33
+ if (kill.headshot) row.headshotKills += 1;
34
+ if (kill.tradeKill) row.tradeKills += 1;
35
+ if (kill.noScope) row.noScopeKills += 1;
36
+ if (kill.throughSmoke) row.throughSmokeKills += 1;
37
+ if ((kill.penetratedObjects ?? 0) > 0) row.wallbangKills += 1;
38
+ row.penetratedObjects += kill.penetratedObjects ?? 0;
39
+ weaponCounts.set(weapon, row);
40
+ }
41
+
42
+ return playerWeaponHighlightFactsSchema.parse({
43
+ steamId64: player.steamId64,
44
+ totalKills: kills.length,
45
+ weapons: [...weaponCounts.values()]
46
+ .sort((a, b) => b.kills - a.kills || a.weapon.localeCompare(b.weapon)),
47
+ highlights: {
48
+ wallbangKills: stats?.wallbangKillCount ?? kills.filter((kill) => (kill.penetratedObjects ?? 0) > 0).length,
49
+ noScopeKills: stats?.noScopeKillCount ?? kills.filter((kill) => kill.noScope).length,
50
+ throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length,
51
+ collateralKills: stats?.collateralKillCount ?? null
52
+ }
53
+ });
54
+ });
55
+ }
@@ -1,51 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { EconomyPoint } from "@cs2dak/contract";
3
- import { buildEconomyConversion, economyLabelCn } 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
- });
38
-
39
- describe("economyLabelCn", () => {
40
- it("maps known economy types to Chinese labels", () => {
41
- expect(economyLabelCn("full")).toBe("全枪全弹");
42
- expect(economyLabelCn("ECO")).toBe("纯ECO");
43
- // conversion 与 full 同义(长枪局),不单独区分。
44
- expect(economyLabelCn("conversion")).toBe(economyLabelCn("full"));
45
- });
46
-
47
- it("passes through unknowns and empties", () => {
48
- expect(economyLabelCn(null)).toBe("");
49
- expect(economyLabelCn("mystery")).toBe("mystery");
50
- });
51
- });
package/src/economy.ts DELETED
@@ -1,76 +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
- const ECONOMY_LABELS_CN: Record<string, string> = {
20
- pistol: "手枪局",
21
- eco: "纯ECO",
22
- semi: "半起",
23
- force: "强起",
24
- full: "全枪全弹",
25
- // conversion = 长枪局,与 full 同义,不单独区分。
26
- conversion: "全枪全弹",
27
- };
28
-
29
- /**
30
- * 经济类型中文标签(转化率面板 / 榜单展示用)。
31
- * 统一了 RivalHub `economy-series.ts` 的 `economyLabelCn`。未知值原样返回。
32
- */
33
- export function economyLabelCn(type: string | null | undefined): string {
34
- if (!type) return "";
35
- return ECONOMY_LABELS_CN[type.toLowerCase()] ?? type;
36
- }
37
-
38
- function tally(
39
- acc: Map<string, { played: number; won: number }>,
40
- type: TeamEconomyType,
41
- won: boolean,
42
- ): void {
43
- const g = acc.get(type) ?? { played: 0, won: 0 };
44
- g.played += 1;
45
- if (won) g.won += 1;
46
- acc.set(type, g);
47
- }
48
-
49
- function finalize(acc: Map<string, { played: number; won: number }>): EconomyConversion {
50
- const out: EconomyConversion = {};
51
- for (const [type, s] of acc) {
52
- out[type as TeamEconomyType] = {
53
- played: s.played,
54
- won: s.won,
55
- winRate: s.played > 0 ? s.won / s.played : 0,
56
- };
57
- }
58
- return out;
59
- }
60
-
61
- /**
62
- * 经济转化率:按经济类型统计每队的回合胜率。
63
- *
64
- * 直接从 kit 的 `EconomyPoint[]` 派生——每个点已含两队经济类型 + 胜方,
65
- * 无需另一套松散输入。统一并取代 RivalHub `economy-conversion.ts` 的等价逻辑
66
- * (那边按队拆开传入,这里一次产出两队)。纯函数,无副作用。
67
- */
68
- export function buildEconomyConversion(points: EconomyPoint[]): MatchEconomyConversion {
69
- const teamA = new Map<string, { played: number; won: number }>();
70
- const teamB = new Map<string, { played: number; won: number }>();
71
- for (const p of points) {
72
- tally(teamA, p.teamAEconomy, p.winnerTeamKey === "teamA");
73
- tally(teamB, p.teamBEconomy, p.winnerTeamKey === "teamB");
74
- }
75
- return { teamA: finalize(teamA), teamB: finalize(teamB) };
76
- }
@@ -1,32 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { displayWeaponName } from "./weapons";
3
-
4
- describe("displayWeaponName", () => {
5
- it("maps raw codes to canonical display names", () => {
6
- expect(displayWeaponName("ak47")).toBe("AK-47");
7
- expect(displayWeaponName("m4a1_silencer")).toBe("M4A1-S");
8
- expect(displayWeaponName("awp")).toBe("AWP");
9
- expect(displayWeaponName("deagle")).toBe("Desert Eagle");
10
- });
11
-
12
- it("strips the weapon_ prefix and is case-insensitive", () => {
13
- expect(displayWeaponName("weapon_ak47")).toBe("AK-47");
14
- expect(displayWeaponName("WEAPON_AWP")).toBe("AWP");
15
- });
16
-
17
- it("collapses every knife/bayonet variant to a single label", () => {
18
- expect(displayWeaponName("knife")).toBe("knife");
19
- expect(displayWeaponName("knife_karambit")).toBe("knife");
20
- expect(displayWeaponName("weapon_bayonet")).toBe("knife");
21
- });
22
-
23
- it("falls back to the normalized code instead of an unknown placeholder", () => {
24
- expect(displayWeaponName("weapon_some_future_gun")).toBe("some_future_gun");
25
- });
26
-
27
- it("never returns a purely numeric string for a named weapon", () => {
28
- for (const raw of ["ak47", "m4a1_silencer", "knife_m9_bayonet", "future_gun"]) {
29
- expect(/^\d+$/.test(displayWeaponName(raw))).toBe(false);
30
- }
31
- });
32
- });