@cs2dak/core 0.2.1 → 1.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/package.json +2 -2
- package/src/economy.test.ts +1 -15
- package/src/economy.ts +0 -19
- package/src/index.test.ts +39 -52
- package/src/index.ts +19 -4
- package/src/normalize.ts +1 -150
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +1 -1
- package/src/weapon-highlights.ts +55 -0
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- package/src/workspace.ts +0 -726
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cs2dak/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"exports": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@rivalhub/rival-rating": "^0.1.0",
|
|
11
11
|
"jszip": "^3.10.1",
|
|
12
|
-
"@cs2dak/contract": "0.
|
|
12
|
+
"@cs2dak/contract": "1.0.0"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src"
|
package/src/economy.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { EconomyPoint } from "@cs2dak/contract";
|
|
3
|
-
import { buildEconomyConversion
|
|
3
|
+
import { buildEconomyConversion } from "./economy";
|
|
4
4
|
|
|
5
5
|
function pt(p: Partial<EconomyPoint> & Pick<EconomyPoint, "teamAEconomy" | "teamBEconomy" | "winnerTeamKey">): EconomyPoint {
|
|
6
6
|
return {
|
|
@@ -35,17 +35,3 @@ describe("buildEconomyConversion", () => {
|
|
|
35
35
|
expect(buildEconomyConversion([])).toEqual({ teamA: {}, teamB: {} });
|
|
36
36
|
});
|
|
37
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
CHANGED
|
@@ -16,25 +16,6 @@ export interface MatchEconomyConversion {
|
|
|
16
16
|
teamB: EconomyConversion;
|
|
17
17
|
}
|
|
18
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
19
|
function tally(
|
|
39
20
|
acc: Map<string, { played: number; won: number }>,
|
|
40
21
|
type: TeamEconomyType,
|
package/src/index.test.ts
CHANGED
|
@@ -3,69 +3,34 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import {
|
|
5
5
|
analyzeDemoPackage,
|
|
6
|
-
buildMatchWorkspaceModel,
|
|
7
|
-
buildDemoViewModel,
|
|
8
6
|
computeAccountRatingsV2,
|
|
9
7
|
deriveAccountSignalsV2,
|
|
8
|
+
derivePlayerWeaponHighlights,
|
|
10
9
|
deriveRRIndicators,
|
|
11
10
|
loadDemoPackageFromZip
|
|
12
11
|
} from "./index";
|
|
13
12
|
|
|
14
13
|
describe("analyzeDemoPackage", () => {
|
|
15
|
-
it("builds a match workspace model with replay and user-facing modules from a strict v2 export", async () => {
|
|
16
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
17
|
-
const pkg = await loadDemoPackageFromZip(zip);
|
|
18
|
-
const workspace = buildMatchWorkspaceModel(pkg);
|
|
19
|
-
|
|
20
|
-
expect(workspace.version).toBe("cs2-demo-analysis-kit/workspace-0.1");
|
|
21
|
-
expect(workspace.title).toBe("Team A vs Team B");
|
|
22
|
-
expect(workspace.tabs.map((tab) => tab.key)).toEqual(["overview", "rounds", "players", "economy", "map", "replay"]);
|
|
23
|
-
expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("qa");
|
|
24
|
-
expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("economySwing");
|
|
25
|
-
expect(workspace.rounds).toHaveLength(21);
|
|
26
|
-
expect(workspace.rounds[0]?.events.some((event) => event.type === "kill")).toBe(true);
|
|
27
|
-
expect(workspace.players).toHaveLength(10);
|
|
28
|
-
expect(workspace.players[0]?.rrBreakdown.length).toBe(5);
|
|
29
|
-
expect(workspace.map.modes.map((mode) => mode.key)).toEqual(["death", "kill", "grenade", "bomb", "position"]);
|
|
30
|
-
expect(workspace.replay.available).toBe(true);
|
|
31
|
-
expect(workspace.replay.rounds.length).toBeGreaterThan(0);
|
|
32
|
-
expect(workspace.replay.rounds[0]?.players[0]?.frames.length).toBeGreaterThan(0);
|
|
33
|
-
expect(workspace.replay.capabilities.hasDefuseKit).toBe(true);
|
|
34
|
-
expect(workspace.replay.capabilities.hasBombPosition).toBe(false);
|
|
35
|
-
expect(workspace.replay.rounds.some((round) => round.players.some((player) => player.frames.some((frame) => frame.weapon && /^\d+$/.test(frame.weapon))))).toBe(false);
|
|
36
|
-
expect(workspace.overview.story.length).toBeGreaterThan(0);
|
|
37
|
-
// 开场 beat 必出:含地图显示名 + 比分,且用胜负动词之一描述结果。
|
|
38
|
-
expect(workspace.overview.story[0]).toContain("Ancient");
|
|
39
|
-
expect(workspace.overview.story[0]).toContain("13:8");
|
|
40
|
-
expect(workspace.overview.story[0]).toMatch(/碾压|险胜|拿下/);
|
|
41
|
-
expect(workspace.overview.story.join("\n")).not.toContain("打成");
|
|
42
|
-
expect(workspace.adminQa.summary.errorCount).toBe(0);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
14
|
it("builds reusable analysis and view-model artifacts from a strict v2 export", async () => {
|
|
46
15
|
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
47
16
|
const pkg = await loadDemoPackageFromZip(zip);
|
|
48
17
|
const bundle = analyzeDemoPackage(pkg);
|
|
49
|
-
const viewModel = buildDemoViewModel(bundle);
|
|
50
|
-
|
|
51
18
|
expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
|
|
52
|
-
expect(bundle.version).toBe("cs2-demo-analysis-kit/0
|
|
19
|
+
expect(bundle.version).toBe("cs2-demo-analysis-kit/1.0");
|
|
20
|
+
expect(bundle.provenance.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
|
|
21
|
+
expect(bundle.provenance.ratingVersions.rr).toBeTruthy();
|
|
53
22
|
expect(bundle.scoreboard).toHaveLength(10);
|
|
54
23
|
expect(bundle.scoreboard[0]?.rr).toBeGreaterThan(0);
|
|
55
|
-
expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(
|
|
56
|
-
expect(bundle.playerRoundFacts).toHaveLength(
|
|
57
|
-
expect(bundle.economy).toHaveLength(
|
|
58
|
-
expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(
|
|
59
|
-
expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(
|
|
60
|
-
expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(
|
|
24
|
+
expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(16);
|
|
25
|
+
expect(bundle.playerRoundFacts).toHaveLength(160);
|
|
26
|
+
expect(bundle.economy).toHaveLength(16);
|
|
27
|
+
expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(119);
|
|
28
|
+
expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(51);
|
|
29
|
+
expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(119);
|
|
61
30
|
expect(bundle.timeline.some((event) => event.type === "kill")).toBe(true);
|
|
62
31
|
expect(bundle.heatmap.some((point) => point.kind === "death")).toBe(true);
|
|
63
32
|
expect(bundle.timeline.find((event) => event.type === "round-end")?.clockPhase).toBe("round-end");
|
|
64
33
|
expect(bundle.timeline.find((event) => event.type === "kill")?.clockLabel).toMatch(/^\d:\d{2}$/);
|
|
65
|
-
expect(viewModel.scoreline).toBe("13:8");
|
|
66
|
-
expect(viewModel.map.name).toBe("de_ancient");
|
|
67
|
-
expect(viewModel.map.radarImageUrl).toBe("./maps/radars/de_ancient.png");
|
|
68
|
-
expect(viewModel.qa.summary.errorCount).toBe(0);
|
|
69
34
|
});
|
|
70
35
|
|
|
71
36
|
it("derives value-account signals and computes v2 RR from the strict v2 fixture", async () => {
|
|
@@ -76,10 +41,10 @@ describe("analyzeDemoPackage", () => {
|
|
|
76
41
|
|
|
77
42
|
expect(signals).toHaveLength(10);
|
|
78
43
|
expect(ratings).toHaveLength(10);
|
|
79
|
-
expect(signals[0]?.rounds).toBe(
|
|
80
|
-
expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage:
|
|
81
|
-
expect(signals[0]?.combat.killsByManState).toEqual({ manDown:
|
|
82
|
-
expect(signals[0]?.trade.tradedOpeningDeaths).toBe(
|
|
44
|
+
expect(signals[0]?.rounds).toBe(16);
|
|
45
|
+
expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 2, even: 3, advantage: 8 });
|
|
46
|
+
expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 2, even: 5, manUp: 6 });
|
|
47
|
+
expect(signals[0]?.trade.tradedOpeningDeaths).toBe(0);
|
|
83
48
|
expect(ratings[0]?.rr.model).toBe("value-accounts-v2-lite");
|
|
84
49
|
expect(ratings[0]?.rr.rr).toBeGreaterThan(0);
|
|
85
50
|
});
|
|
@@ -152,11 +117,11 @@ describe("analyzeDemoPackage", () => {
|
|
|
152
117
|
const bundle = analyzeDemoPackage(pkg);
|
|
153
118
|
const row = bundle.scoreboard[0]!;
|
|
154
119
|
|
|
155
|
-
expect(row.combatDeathCount).toBe(
|
|
120
|
+
expect(row.combatDeathCount).toBe(9);
|
|
156
121
|
expect(row.bombDeathCount).toBe(0);
|
|
157
|
-
expect(row.bombPlantCount).toBe(
|
|
122
|
+
expect(row.bombPlantCount).toBe(0);
|
|
158
123
|
expect(row.noScopeKillCount).toBe(0);
|
|
159
|
-
expect(row.throughSmokeKillCount).toBe(
|
|
124
|
+
expect(row.throughSmokeKillCount).toBe(0);
|
|
160
125
|
expect(row.fieldAvailability).toEqual({
|
|
161
126
|
playerStats: "available",
|
|
162
127
|
economy: "available",
|
|
@@ -168,6 +133,28 @@ describe("analyzeDemoPackage", () => {
|
|
|
168
133
|
expect(row.confidence).toBeCloseTo(0.917, 3);
|
|
169
134
|
});
|
|
170
135
|
|
|
136
|
+
it("derives reusable per-player weapon kill distribution and highlight facts", async () => {
|
|
137
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
138
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
139
|
+
const facts = derivePlayerWeaponHighlights(pkg);
|
|
140
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
141
|
+
|
|
142
|
+
expect(facts).toHaveLength(pkg.players.length);
|
|
143
|
+
expect(bundle.playerWeaponHighlights).toEqual(facts);
|
|
144
|
+
expect(facts.every((row) => row.weapons.reduce((sum, weapon) => sum + weapon.kills, 0) === row.totalKills)).toBe(true);
|
|
145
|
+
expect(facts.flatMap((row) => row.weapons).some((weapon) => weapon.weapon === "ak47")).toBe(true);
|
|
146
|
+
expect(facts.flatMap((row) => row.weapons).every((weapon) =>
|
|
147
|
+
weapon.headshotKills <= weapon.kills
|
|
148
|
+
&& weapon.tradeKills <= weapon.kills
|
|
149
|
+
&& weapon.noScopeKills <= weapon.kills
|
|
150
|
+
&& weapon.throughSmokeKills <= weapon.kills
|
|
151
|
+
&& weapon.wallbangKills <= weapon.kills
|
|
152
|
+
)).toBe(true);
|
|
153
|
+
expect(facts.every((row) => row.highlights.wallbangKills != null)).toBe(true);
|
|
154
|
+
expect(facts.every((row) => row.highlights.noScopeKills != null)).toBe(true);
|
|
155
|
+
expect(facts.every((row) => row.highlights.throughSmokeKills != null)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
171
158
|
it("emits null context buckets (not zero) when the data source is missing", async () => {
|
|
172
159
|
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
173
160
|
const pkg = await loadDemoPackageFromZip(zip);
|
package/src/index.ts
CHANGED
|
@@ -4,19 +4,21 @@ import { buildQaReport } from "./qa.js";
|
|
|
4
4
|
import { buildPlayerRoundFacts, buildPlayerIndicators, buildScoreboard } from "./scoreboard.js";
|
|
5
5
|
import { computeAccountRatingsV2 } from "./signals.js";
|
|
6
6
|
import { buildTimeline, buildEconomy, buildHeatmap } from "./timeline.js";
|
|
7
|
+
import { buildPlayerWeaponHighlights } from "./weapon-highlights.js";
|
|
7
8
|
|
|
8
9
|
export { loadDemoPackageFromZip } from "./loader.js";
|
|
9
10
|
export { normalizeDemoPackage } from "./normalize.js";
|
|
10
11
|
export { deriveAccountSignalsV2, computeAccountRatingsV2 } from "./signals.js";
|
|
11
12
|
export { deriveRRIndicators } from "./scoreboard.js";
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
14
|
-
export {
|
|
13
|
+
export { derivePlayerWeaponHighlights } from "./weapon-highlights.js";
|
|
14
|
+
export { buildEconomyConversion } from "./economy.js";
|
|
15
|
+
export { buildTeamSideWinRates } from "./side-win-rate.js";
|
|
15
16
|
export type {
|
|
16
17
|
EconomyTypeStats,
|
|
17
18
|
EconomyConversion,
|
|
18
19
|
MatchEconomyConversion,
|
|
19
20
|
} from "./economy.js";
|
|
21
|
+
export type { SideWinRateStats, TeamSideWinRates } from "./side-win-rate.js";
|
|
20
22
|
|
|
21
23
|
export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
22
24
|
const pkg = normalizeDemoPackage(input);
|
|
@@ -25,13 +27,25 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
|
25
27
|
const playerIndicators = buildPlayerIndicators(pkg, playerRoundFacts);
|
|
26
28
|
const accountRatings = computeAccountRatingsV2(pkg);
|
|
27
29
|
const scoreboard = buildScoreboard(pkg, playerIndicators, accountRatings);
|
|
30
|
+
const playerWeaponHighlights = buildPlayerWeaponHighlights(pkg);
|
|
28
31
|
const timeline = buildTimeline(pkg);
|
|
29
32
|
const economy = buildEconomy(pkg);
|
|
30
33
|
const heatmap = buildHeatmap(pkg);
|
|
31
34
|
|
|
32
35
|
return analysisBundleSchema.parse({
|
|
33
|
-
version: "cs2-demo-analysis-kit/0
|
|
36
|
+
version: "cs2-demo-analysis-kit/1.0",
|
|
34
37
|
sourceSchemaVersion: pkg.manifest.schemaVersion,
|
|
38
|
+
provenance: {
|
|
39
|
+
analysisVersion: "cs2-demo-analysis-kit/1.0",
|
|
40
|
+
sourceSchemaVersion: pkg.manifest.schemaVersion,
|
|
41
|
+
sourceDemoHash: pkg.manifest.demo?.hash ?? null,
|
|
42
|
+
exporter: pkg.manifest.exporter,
|
|
43
|
+
parser: pkg.manifest.parser,
|
|
44
|
+
ratingVersions: {
|
|
45
|
+
rr: playerIndicators[0]?.rr.weightsVersion ?? null,
|
|
46
|
+
valueAccounts: accountRatings[0]?.rr.weightsVersion ?? null
|
|
47
|
+
}
|
|
48
|
+
},
|
|
35
49
|
mapName: pkg.match.mapName,
|
|
36
50
|
tickrate: pkg.match.tickrate,
|
|
37
51
|
teams: {
|
|
@@ -39,6 +53,7 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
|
39
53
|
teamB: { name: pkg.match.teamB.name ?? "Team B", score: pkg.match.teamB.score }
|
|
40
54
|
},
|
|
41
55
|
scoreboard,
|
|
56
|
+
playerWeaponHighlights,
|
|
42
57
|
playerIndicators,
|
|
43
58
|
playerRoundFacts,
|
|
44
59
|
timeline,
|
package/src/normalize.ts
CHANGED
|
@@ -1,158 +1,9 @@
|
|
|
1
|
-
import { demoPackageSchema, type DemoPackage
|
|
1
|
+
import { demoPackageSchema, type DemoPackage } from "@cs2dak/contract";
|
|
2
2
|
|
|
3
3
|
export function parsePackageJson(text: string): unknown {
|
|
4
4
|
return JSON.parse(text.replace(/\bNaN\b/g, "null"));
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export function normalizeDemoPackage(input: unknown): DemoPackage {
|
|
8
|
-
const raw = input as Record<string, unknown>;
|
|
9
|
-
const manifest = raw.manifest as Record<string, unknown> | undefined;
|
|
10
|
-
|
|
11
|
-
if (manifest?.schemaVersion === "cs2-demo-format/1.0") {
|
|
12
|
-
return demoPackageSchema.parse(normalizeV1Package(raw));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
8
|
return demoPackageSchema.parse(input);
|
|
16
9
|
}
|
|
17
|
-
|
|
18
|
-
function normalizeV1Package(raw: Record<string, unknown>): Record<string, unknown> {
|
|
19
|
-
const match = raw.match as Record<string, unknown>;
|
|
20
|
-
const rounds = asRecords(raw.rounds).filter((round) => numberValue(round.roundNumber) > 0);
|
|
21
|
-
const roundByNumber = new Map(rounds.map((round) => [numberValue(round.roundNumber), round]));
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
manifest: {
|
|
25
|
-
...(raw.manifest as Record<string, unknown>),
|
|
26
|
-
schemaVersion: "cs2-demo-format/2.0"
|
|
27
|
-
},
|
|
28
|
-
match: {
|
|
29
|
-
...match,
|
|
30
|
-
durationSeconds: numberValue(match.durationSeconds) > 0 ? match.durationSeconds : undefined
|
|
31
|
-
},
|
|
32
|
-
players: raw.players,
|
|
33
|
-
rounds: rounds.map((round) => normalizeV1Round(round)),
|
|
34
|
-
playerEconomies: asRecords(raw.playerEconomies)
|
|
35
|
-
.filter((row) => numberValue(row.roundNumber) > 0)
|
|
36
|
-
.map((row) => ({ ...row, type: normalizeEconomyType(row.type) })),
|
|
37
|
-
playerStats: raw.playerStats ?? [],
|
|
38
|
-
kills: asRecords(raw.kills)
|
|
39
|
-
.filter((kill) => numberValue(kill.roundNumber) > 0)
|
|
40
|
-
.map((kill) => normalizeV1Kill(kill, roundByNumber)),
|
|
41
|
-
damages: asRecords(raw.damages)
|
|
42
|
-
.filter((row) => numberValue(row.roundNumber) > 0)
|
|
43
|
-
.map((row) => normalizeV1Damage(row, roundByNumber)),
|
|
44
|
-
blinds: asRecords(raw.blinds).filter((row) => numberValue(row.roundNumber) > 0),
|
|
45
|
-
grenades: asRecords(raw.grenades)
|
|
46
|
-
.filter((row) => numberValue(row.roundNumber) > 0)
|
|
47
|
-
.map((row) => normalizeV1Grenade(row, roundByNumber)),
|
|
48
|
-
clutches: asRecords(raw.clutches).filter((row) => numberValue(row.roundNumber) > 0)
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function asRecords(value: unknown): Record<string, unknown>[] {
|
|
53
|
-
return Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object" && !Array.isArray(row)) : [];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function normalizeV1Round(round: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
-
return {
|
|
58
|
-
...round,
|
|
59
|
-
startTick: positiveInt(round.startTick),
|
|
60
|
-
freezeEndTick: positiveInt(round.freezeEndTick),
|
|
61
|
-
endTick: positiveInt(round.endTick),
|
|
62
|
-
teamASide: normalizeSide(round.teamASide) ?? "t",
|
|
63
|
-
teamBSide: normalizeSide(round.teamBSide) ?? "ct",
|
|
64
|
-
teamAEconomy: normalizeEconomyType(round.teamAEconomy),
|
|
65
|
-
teamBEconomy: normalizeEconomyType(round.teamBEconomy),
|
|
66
|
-
winnerSide: normalizeSide(round.winnerSide) ?? sideForTeam(round.winnerTeamKey, round) ?? "t"
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function normalizeV1Kill(kill: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
|
|
71
|
-
const round = rounds.get(numberValue(kill.roundNumber));
|
|
72
|
-
return {
|
|
73
|
-
...kill,
|
|
74
|
-
tick: positiveInt(kill.tick),
|
|
75
|
-
killerTeamKey: normalizeTeamKey(kill.killerTeamKey),
|
|
76
|
-
victimTeamKey: normalizeTeamKey(kill.victimTeamKey) ?? "teamA",
|
|
77
|
-
killerSide: normalizeSide(kill.killerSide) ?? sideForTeam(kill.killerTeamKey, round),
|
|
78
|
-
victimSide: normalizeSide(kill.victimSide) ?? sideForTeam(kill.victimTeamKey, round) ?? "t",
|
|
79
|
-
killerPosition: sanitizeNullablePosition(kill.killerPosition),
|
|
80
|
-
victimPosition: sanitizePosition(kill.victimPosition)
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function normalizeV1Damage(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
|
|
85
|
-
const round = rounds.get(numberValue(row.roundNumber));
|
|
86
|
-
return {
|
|
87
|
-
...row,
|
|
88
|
-
tick: positiveInt(row.tick),
|
|
89
|
-
weapon: typeof row.weapon === "string" && row.weapon.length > 0 ? row.weapon : "unknown",
|
|
90
|
-
attackerTeamKey: normalizeTeamKey(row.attackerTeamKey),
|
|
91
|
-
victimTeamKey: normalizeTeamKey(row.victimTeamKey) ?? "teamA",
|
|
92
|
-
attackerSide: normalizeSide(row.attackerSide) ?? sideForTeam(row.attackerTeamKey, round),
|
|
93
|
-
victimSide: normalizeSide(row.victimSide) ?? sideForTeam(row.victimTeamKey, round) ?? "t",
|
|
94
|
-
attackerPosition: sanitizeNullablePosition(row.attackerPosition),
|
|
95
|
-
victimPosition: sanitizeNullablePosition(row.victimPosition) ?? undefined
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function normalizeV1Grenade(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
|
|
100
|
-
const round = rounds.get(numberValue(row.roundNumber));
|
|
101
|
-
const teamKey = normalizeTeamKey(row.throwerTeamKey ?? row.teamKey);
|
|
102
|
-
return {
|
|
103
|
-
roundNumber: row.roundNumber,
|
|
104
|
-
tick: positiveInt(row.effectTick ?? row.throwTick ?? row.tick),
|
|
105
|
-
steamId64: row.throwerSteamId64 ?? row.steamId64 ?? null,
|
|
106
|
-
teamKey,
|
|
107
|
-
side: normalizeSide(row.throwerSide ?? row.side) ?? sideForTeam(teamKey, round),
|
|
108
|
-
grenadeType: typeof row.grenade === "string" && row.grenade.length > 0 ? row.grenade : row.grenadeType ?? "unknown",
|
|
109
|
-
eventType: row.eventType ?? "effect",
|
|
110
|
-
position: sanitizeNullablePosition(row.effectPosition ?? row.throwPosition ?? row.position)
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function sideForTeam(teamKey: unknown, round: Record<string, unknown> | undefined): "t" | "ct" | null {
|
|
115
|
-
const team = normalizeTeamKey(teamKey);
|
|
116
|
-
if (!team || !round) {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
return normalizeSide(team === "teamA" ? round.teamASide : round.teamBSide);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function normalizeTeamKey(value: unknown): TeamKey | null {
|
|
123
|
-
return value === "teamA" || value === "teamB" ? value : null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function normalizeSide(value: unknown): "t" | "ct" | null {
|
|
127
|
-
return value === "t" || value === "ct" ? value : null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function normalizeEconomyType(value: unknown): "pistol" | "eco" | "semi" | "force" | "full" | "conversion" {
|
|
131
|
-
return value === "pistol" || value === "eco" || value === "semi" || value === "force" || value === "full" || value === "conversion" ? value : "full";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function numberValue(value: unknown): number {
|
|
135
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function positiveInt(value: unknown): number {
|
|
139
|
-
return Math.max(1, Math.trunc(numberValue(value)));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function sanitizeNullablePosition(value: unknown): { x: number; y: number; z: number } | null {
|
|
143
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
const point = value as Record<string, unknown>;
|
|
147
|
-
const x = numberValue(point.x);
|
|
148
|
-
const y = numberValue(point.y);
|
|
149
|
-
const z = numberValue(point.z);
|
|
150
|
-
if (x === 0 && y === 0 && z === 0) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
return { x, y, z };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function sanitizePosition(value: unknown): { x: number; y: number; z: number } {
|
|
157
|
-
return sanitizeNullablePosition(value) ?? { x: 0, y: 0, z: 0 };
|
|
158
|
-
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
3
|
+
import { buildTeamSideWinRates } from "./side-win-rate";
|
|
4
|
+
|
|
5
|
+
describe("buildTeamSideWinRates", () => {
|
|
6
|
+
it("aggregates T/CT win rates for both teams from canonical rounds", () => {
|
|
7
|
+
const pkg = {
|
|
8
|
+
rounds: [
|
|
9
|
+
{ teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamA" },
|
|
10
|
+
{ teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamB" },
|
|
11
|
+
{ teamASide: "t", teamBSide: "ct", winnerTeamKey: "teamA" }
|
|
12
|
+
]
|
|
13
|
+
} as DemoPackage;
|
|
14
|
+
|
|
15
|
+
expect(buildTeamSideWinRates(pkg)).toEqual({
|
|
16
|
+
teamA: {
|
|
17
|
+
ct: { played: 2, won: 1, winRate: 0.5 },
|
|
18
|
+
t: { played: 1, won: 1, winRate: 1 }
|
|
19
|
+
},
|
|
20
|
+
teamB: {
|
|
21
|
+
t: { played: 2, won: 1, winRate: 0.5 },
|
|
22
|
+
ct: { played: 1, won: 0, winRate: 0 }
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns empty team summaries when rounds are unavailable", () => {
|
|
28
|
+
expect(buildTeamSideWinRates({ rounds: [] } as unknown as DemoPackage)).toEqual({
|
|
29
|
+
teamA: {},
|
|
30
|
+
teamB: {}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DemoPackage, Side, TeamKey } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
export interface SideWinRateStats {
|
|
4
|
+
played: number;
|
|
5
|
+
won: number;
|
|
6
|
+
winRate: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type TeamSideWinRates = Record<TeamKey, Partial<Record<Side, SideWinRateStats>>>;
|
|
10
|
+
|
|
11
|
+
interface SideRoundResult {
|
|
12
|
+
side: Side;
|
|
13
|
+
won: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function aggregateSideWinRates(rows: SideRoundResult[]): Partial<Record<Side, SideWinRateStats>> {
|
|
17
|
+
const groups = new Map<Side, { played: number; won: number }>();
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
const group = groups.get(row.side) ?? { played: 0, won: 0 };
|
|
20
|
+
group.played += 1;
|
|
21
|
+
if (row.won) group.won += 1;
|
|
22
|
+
groups.set(row.side, group);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Object.fromEntries(
|
|
26
|
+
[...groups.entries()].map(([side, stats]) => [
|
|
27
|
+
side,
|
|
28
|
+
{ ...stats, winRate: stats.played > 0 ? stats.won / stats.played : 0 }
|
|
29
|
+
])
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 按 T/CT side 汇总一场比赛中两队的回合胜率。
|
|
35
|
+
*
|
|
36
|
+
* 取代产品从数据库行重新拼装的 half-side 聚合;产品只负责选择 DemoPackage。
|
|
37
|
+
*/
|
|
38
|
+
export function buildTeamSideWinRates(pkg: DemoPackage): TeamSideWinRates {
|
|
39
|
+
return {
|
|
40
|
+
teamA: aggregateSideWinRates(pkg.rounds.map((round) => ({
|
|
41
|
+
side: round.teamASide,
|
|
42
|
+
won: round.winnerTeamKey === "teamA"
|
|
43
|
+
}))),
|
|
44
|
+
teamB: aggregateSideWinRates(pkg.rounds.map((round) => ({
|
|
45
|
+
side: round.teamBSide,
|
|
46
|
+
won: round.winnerTeamKey === "teamB"
|
|
47
|
+
})))
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/signals.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
|
|
|
48
48
|
return {
|
|
49
49
|
steamId64: player.steamId64,
|
|
50
50
|
rounds,
|
|
51
|
-
sourceVersion: "cs2-demo-analysis-kit/0
|
|
51
|
+
sourceVersion: "cs2-demo-analysis-kit/1.0",
|
|
52
52
|
combat: {
|
|
53
53
|
kills: stats?.kills ?? playerKills.length,
|
|
54
54
|
deaths: stats?.deaths ?? playerDeaths.length,
|
|
@@ -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 statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
|
|
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);
|
|
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
|
+
}
|
package/src/weapons.test.ts
DELETED
|
@@ -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
|
-
});
|