@cs2dak/core 0.2.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 +21 -0
- package/README.md +7 -0
- package/package.json +20 -0
- package/src/economy.test.ts +51 -0
- package/src/economy.ts +76 -0
- package/src/index.test.ts +184 -0
- package/src/index.ts +49 -0
- package/src/loader.ts +48 -0
- package/src/normalize.ts +158 -0
- package/src/qa.ts +198 -0
- package/src/scoreboard.ts +288 -0
- package/src/signals.ts +232 -0
- package/src/timeline.ts +209 -0
- package/src/utils.ts +133 -0
- package/src/weapons.test.ts +32 -0
- package/src/weapons.ts +93 -0
- package/src/workspace.ts +726 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Starfie1d
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @cs2dak/core
|
|
2
|
+
|
|
3
|
+
English | 简体中文
|
|
4
|
+
|
|
5
|
+
Pure demo analysis engine. It accepts a parsed `DemoPackage`, produces an `AnalysisBundle`, and derives a `DemoViewModel`.
|
|
6
|
+
|
|
7
|
+
纯分析引擎。输入标准化后的 `DemoPackage`,输出 `AnalysisBundle` 和 `DemoViewModel`。这里不允许依赖 React、数据库、Next.js、FastAPI 或 RivalHub 业务代码。
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cs2dak/core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@rivalhub/rival-rating": "^0.1.0",
|
|
11
|
+
"jszip": "^3.10.1",
|
|
12
|
+
"@cs2dak/contract": "0.2.0"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
analyzeDemoPackage,
|
|
6
|
+
buildMatchWorkspaceModel,
|
|
7
|
+
buildDemoViewModel,
|
|
8
|
+
computeAccountRatingsV2,
|
|
9
|
+
deriveAccountSignalsV2,
|
|
10
|
+
deriveRRIndicators,
|
|
11
|
+
loadDemoPackageFromZip
|
|
12
|
+
} from "./index";
|
|
13
|
+
|
|
14
|
+
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
|
+
it("builds reusable analysis and view-model artifacts from a strict v2 export", async () => {
|
|
46
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
47
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
48
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
49
|
+
const viewModel = buildDemoViewModel(bundle);
|
|
50
|
+
|
|
51
|
+
expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
|
|
52
|
+
expect(bundle.version).toBe("cs2-demo-analysis-kit/0.2");
|
|
53
|
+
expect(bundle.scoreboard).toHaveLength(10);
|
|
54
|
+
expect(bundle.scoreboard[0]?.rr).toBeGreaterThan(0);
|
|
55
|
+
expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(21);
|
|
56
|
+
expect(bundle.playerRoundFacts).toHaveLength(210);
|
|
57
|
+
expect(bundle.economy).toHaveLength(21);
|
|
58
|
+
expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(142);
|
|
59
|
+
expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(94);
|
|
60
|
+
expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(142);
|
|
61
|
+
expect(bundle.timeline.some((event) => event.type === "kill")).toBe(true);
|
|
62
|
+
expect(bundle.heatmap.some((point) => point.kind === "death")).toBe(true);
|
|
63
|
+
expect(bundle.timeline.find((event) => event.type === "round-end")?.clockPhase).toBe("round-end");
|
|
64
|
+
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
|
+
});
|
|
70
|
+
|
|
71
|
+
it("derives value-account signals and computes v2 RR from the strict v2 fixture", async () => {
|
|
72
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
73
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
74
|
+
const signals = deriveAccountSignalsV2(pkg);
|
|
75
|
+
const ratings = computeAccountRatingsV2(pkg);
|
|
76
|
+
|
|
77
|
+
expect(signals).toHaveLength(10);
|
|
78
|
+
expect(ratings).toHaveLength(10);
|
|
79
|
+
expect(signals[0]?.rounds).toBe(21);
|
|
80
|
+
expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 0, even: 2, advantage: 5 });
|
|
81
|
+
expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 1, even: 5, manUp: 1 });
|
|
82
|
+
expect(signals[0]?.trade.tradedOpeningDeaths).toBe(1);
|
|
83
|
+
expect(ratings[0]?.rr.model).toBe("value-accounts-v2-lite");
|
|
84
|
+
expect(ratings[0]?.rr.rr).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("anchors accountRR so the per-match league mean is ~1.0", async () => {
|
|
88
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
89
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
90
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
91
|
+
|
|
92
|
+
const accountRRs = bundle.scoreboard.map((row) => row.accountRR);
|
|
93
|
+
const mean = accountRRs.reduce((sum, v) => sum + v, 0) / accountRRs.length;
|
|
94
|
+
expect(mean).toBeCloseTo(1.0, 2);
|
|
95
|
+
// 锚定后必然有人高于、有人低于 1.0
|
|
96
|
+
expect(accountRRs.some((v) => v > 1.0)).toBe(true);
|
|
97
|
+
expect(accountRRs.some((v) => v < 1.0)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("exposes RRIndicators without rebuilding them in cohort callers", async () => {
|
|
101
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
102
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
103
|
+
const indicators = deriveRRIndicators(pkg);
|
|
104
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
105
|
+
|
|
106
|
+
expect(indicators).toHaveLength(10);
|
|
107
|
+
expect(indicators[0]).toEqual(bundle.playerIndicators[0]?.indicators);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("wires player-stats truth into RRIndicators instead of legacy approximations", async () => {
|
|
111
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
112
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
113
|
+
const statsTruth = pkg.playerStats[0]!;
|
|
114
|
+
const patchedStats = {
|
|
115
|
+
...statsTruth,
|
|
116
|
+
deaths: statsTruth.deaths + 2,
|
|
117
|
+
combatDeathCount: statsTruth.deaths,
|
|
118
|
+
bombDeathCount: 2
|
|
119
|
+
};
|
|
120
|
+
const patchedPkg = {
|
|
121
|
+
...pkg,
|
|
122
|
+
playerStats: pkg.playerStats.map((row) => row.steamId64 === statsTruth.steamId64 ? patchedStats : row)
|
|
123
|
+
};
|
|
124
|
+
const indicators = deriveRRIndicators(patchedPkg);
|
|
125
|
+
const bombDeathStats = patchedStats;
|
|
126
|
+
const wallbangStats = pkg.playerStats.find((row) => row.wallbangKillCount > 0)!;
|
|
127
|
+
|
|
128
|
+
const bombDeathIndicators = indicators.find((row) => row.steamId64 === bombDeathStats.steamId64)!;
|
|
129
|
+
const wallbangIndicators = indicators.find((row) => row.steamId64 === wallbangStats.steamId64)!;
|
|
130
|
+
|
|
131
|
+
expect(bombDeathStats.deaths).not.toBe(bombDeathStats.combatDeathCount);
|
|
132
|
+
expect(bombDeathIndicators.combatDeathCount).toBe(bombDeathStats.combatDeathCount);
|
|
133
|
+
expect(bombDeathIndicators.bombDeathCount).toBe(bombDeathStats.bombDeathCount);
|
|
134
|
+
expect(wallbangIndicators.wallbangKillCount).toBe(wallbangStats.wallbangKillCount);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("surfaces account breakdown and context status on the scoreboard", async () => {
|
|
138
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
139
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
140
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
141
|
+
const row = bundle.scoreboard[0]!;
|
|
142
|
+
|
|
143
|
+
expect(row.accountBreakdown.combat).toBeGreaterThan(0);
|
|
144
|
+
// 该 fixture 含经济与回合数据 → 两个 context 维度都 available
|
|
145
|
+
expect(row.accountContextStatus.buyDelta).toBe("available");
|
|
146
|
+
expect(row.accountContextStatus.manState).toBe("available");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("surfaces rich v2 fields and confidence on the scoreboard", async () => {
|
|
150
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
151
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
152
|
+
const bundle = analyzeDemoPackage(pkg);
|
|
153
|
+
const row = bundle.scoreboard[0]!;
|
|
154
|
+
|
|
155
|
+
expect(row.combatDeathCount).toBe(11);
|
|
156
|
+
expect(row.bombDeathCount).toBe(0);
|
|
157
|
+
expect(row.bombPlantCount).toBe(4);
|
|
158
|
+
expect(row.noScopeKillCount).toBe(0);
|
|
159
|
+
expect(row.throughSmokeKillCount).toBe(2);
|
|
160
|
+
expect(row.fieldAvailability).toEqual({
|
|
161
|
+
playerStats: "available",
|
|
162
|
+
economy: "available",
|
|
163
|
+
rounds: "available",
|
|
164
|
+
richKills: "partial",
|
|
165
|
+
damages: "available",
|
|
166
|
+
bombs: "available"
|
|
167
|
+
});
|
|
168
|
+
expect(row.confidence).toBeCloseTo(0.917, 3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("emits null context buckets (not zero) when the data source is missing", async () => {
|
|
172
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
|
|
173
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
174
|
+
|
|
175
|
+
// 剥离经济源 → buyDelta 降级为 null(而非零桶);manState 仍可用
|
|
176
|
+
const noEconomy = deriveAccountSignalsV2({ ...pkg, playerEconomies: [] });
|
|
177
|
+
expect(noEconomy[0]?.combat.killsByBuyDelta).toBeNull();
|
|
178
|
+
expect(noEconomy[0]?.combat.killsByManState).not.toBeNull();
|
|
179
|
+
|
|
180
|
+
// 剥离回合源 → manState 降级为 null
|
|
181
|
+
const noRounds = deriveAccountSignalsV2({ ...pkg, rounds: [] });
|
|
182
|
+
expect(noRounds[0]?.combat.killsByManState).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { analysisBundleSchema, type AnalysisBundle } from "@cs2dak/contract";
|
|
2
|
+
import { normalizeDemoPackage } from "./normalize.js";
|
|
3
|
+
import { buildQaReport } from "./qa.js";
|
|
4
|
+
import { buildPlayerRoundFacts, buildPlayerIndicators, buildScoreboard } from "./scoreboard.js";
|
|
5
|
+
import { computeAccountRatingsV2 } from "./signals.js";
|
|
6
|
+
import { buildTimeline, buildEconomy, buildHeatmap } from "./timeline.js";
|
|
7
|
+
|
|
8
|
+
export { loadDemoPackageFromZip } from "./loader.js";
|
|
9
|
+
export { normalizeDemoPackage } from "./normalize.js";
|
|
10
|
+
export { deriveAccountSignalsV2, computeAccountRatingsV2 } from "./signals.js";
|
|
11
|
+
export { deriveRRIndicators } from "./scoreboard.js";
|
|
12
|
+
export { buildDemoViewModel, buildMatchWorkspaceModel } from "./workspace.js";
|
|
13
|
+
export { displayWeaponName } from "./weapons.js";
|
|
14
|
+
export { buildEconomyConversion, economyLabelCn } from "./economy.js";
|
|
15
|
+
export type {
|
|
16
|
+
EconomyTypeStats,
|
|
17
|
+
EconomyConversion,
|
|
18
|
+
MatchEconomyConversion,
|
|
19
|
+
} from "./economy.js";
|
|
20
|
+
|
|
21
|
+
export function analyzeDemoPackage(input: unknown): AnalysisBundle {
|
|
22
|
+
const pkg = normalizeDemoPackage(input);
|
|
23
|
+
const qa = buildQaReport(pkg);
|
|
24
|
+
const playerRoundFacts = buildPlayerRoundFacts(pkg);
|
|
25
|
+
const playerIndicators = buildPlayerIndicators(pkg, playerRoundFacts);
|
|
26
|
+
const accountRatings = computeAccountRatingsV2(pkg);
|
|
27
|
+
const scoreboard = buildScoreboard(pkg, playerIndicators, accountRatings);
|
|
28
|
+
const timeline = buildTimeline(pkg);
|
|
29
|
+
const economy = buildEconomy(pkg);
|
|
30
|
+
const heatmap = buildHeatmap(pkg);
|
|
31
|
+
|
|
32
|
+
return analysisBundleSchema.parse({
|
|
33
|
+
version: "cs2-demo-analysis-kit/0.2",
|
|
34
|
+
sourceSchemaVersion: pkg.manifest.schemaVersion,
|
|
35
|
+
mapName: pkg.match.mapName,
|
|
36
|
+
tickrate: pkg.match.tickrate,
|
|
37
|
+
teams: {
|
|
38
|
+
teamA: { name: pkg.match.teamA.name ?? "Team A", score: pkg.match.teamA.score },
|
|
39
|
+
teamB: { name: pkg.match.teamB.name ?? "Team B", score: pkg.match.teamB.score }
|
|
40
|
+
},
|
|
41
|
+
scoreboard,
|
|
42
|
+
playerIndicators,
|
|
43
|
+
playerRoundFacts,
|
|
44
|
+
timeline,
|
|
45
|
+
economy,
|
|
46
|
+
heatmap,
|
|
47
|
+
qa
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
3
|
+
import { normalizeDemoPackage, parsePackageJson } from "./normalize.js";
|
|
4
|
+
|
|
5
|
+
export async function loadDemoPackageFromZip(bytes: ArrayBuffer | Uint8Array): Promise<DemoPackage> {
|
|
6
|
+
const zip = await JSZip.loadAsync(bytes);
|
|
7
|
+
const readJson = async <T>(name: string): Promise<T> => {
|
|
8
|
+
const file = zip.file(name);
|
|
9
|
+
if (!file) {
|
|
10
|
+
throw new Error(`Missing ${name} in demo package`);
|
|
11
|
+
}
|
|
12
|
+
return parsePackageJson(await file.async("string")) as T;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const manifest = await readJson<unknown>("manifest.json");
|
|
16
|
+
const match = await readJson<unknown>("match.json");
|
|
17
|
+
const players = await readJson<unknown>("players.json");
|
|
18
|
+
const rounds = await readJson<unknown>("rounds.json");
|
|
19
|
+
const playerEconomies = await readJson<unknown>("player-economies.json").catch(() => []);
|
|
20
|
+
const playerStats = await readJson<unknown>("player-stats.json").catch(() => []);
|
|
21
|
+
const kills = await readJson<unknown>("kills.json").catch(() => []);
|
|
22
|
+
const damages = await readJson<unknown>("damages.json").catch(() => []);
|
|
23
|
+
const blinds = await readJson<unknown>("blinds.json").catch(() => []);
|
|
24
|
+
const bombs = await readJson<unknown>("bombs.json").catch(() => []);
|
|
25
|
+
const grenades = await readJson<unknown>("grenades.json").catch(() => []);
|
|
26
|
+
const clutches = await readJson<unknown>("clutches.json").catch(() => []);
|
|
27
|
+
const shots = await readJson<unknown>("shots.json").catch(() => undefined);
|
|
28
|
+
const positions1s = await readJson<unknown>("positions-1s.json").catch(() => undefined);
|
|
29
|
+
const replay = await readJson<unknown>("replay.json").catch(() => undefined);
|
|
30
|
+
|
|
31
|
+
return normalizeDemoPackage({
|
|
32
|
+
manifest,
|
|
33
|
+
match,
|
|
34
|
+
players,
|
|
35
|
+
rounds,
|
|
36
|
+
playerEconomies,
|
|
37
|
+
playerStats,
|
|
38
|
+
kills,
|
|
39
|
+
damages,
|
|
40
|
+
blinds,
|
|
41
|
+
bombs,
|
|
42
|
+
grenades,
|
|
43
|
+
clutches,
|
|
44
|
+
shots,
|
|
45
|
+
positions1s,
|
|
46
|
+
replay
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { demoPackageSchema, type DemoPackage, type TeamKey } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
export function parsePackageJson(text: string): unknown {
|
|
4
|
+
return JSON.parse(text.replace(/\bNaN\b/g, "null"));
|
|
5
|
+
}
|
|
6
|
+
|
|
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
|
+
return demoPackageSchema.parse(input);
|
|
16
|
+
}
|
|
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
|
+
}
|