@cs2dak/presentation 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/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,4 @@
1
+ # @cs2dak/presentation
2
+
3
+ Product-neutral view models and display semantics derived from core and cohort results.
4
+ It does not parse demos, compute ratings, query databases, or render React.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@cs2dak/presentation",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "dependencies": {
10
+ "@rivalhub/rival-rating": "^0.1.0",
11
+ "@cs2dak/contract": "1.0.0",
12
+ "@cs2dak/core": "1.0.0"
13
+ },
14
+ "devDependencies": {
15
+ "@cs2dak/cohort": "1.0.0"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { economyLabelCn } from "./economy";
3
+
4
+ describe("economyLabelCn", () => {
5
+ it("maps known economy types to presentation labels", () => {
6
+ expect(economyLabelCn("full")).toBe("全枪全弹");
7
+ expect(economyLabelCn("ECO")).toBe("纯ECO");
8
+ expect(economyLabelCn("conversion")).toBe(economyLabelCn("full"));
9
+ });
10
+
11
+ it("passes through unknowns and empties", () => {
12
+ expect(economyLabelCn(null)).toBe("");
13
+ expect(economyLabelCn("mystery")).toBe("mystery");
14
+ });
15
+ });
package/src/economy.ts ADDED
@@ -0,0 +1,28 @@
1
+ const ECONOMY_LABELS_CN: Record<string, string> = {
2
+ pistol: "手枪局",
3
+ eco: "纯ECO",
4
+ semi: "半起",
5
+ force: "强起",
6
+ full: "全枪全弹",
7
+ // conversion = 长枪局,与 full 同义,不单独区分。
8
+ conversion: "全枪全弹",
9
+ };
10
+
11
+ /** 紧凑版经济标签(图表图例 / SVG 内联用)。 */
12
+ export const ECONOMY_LABEL_SHORT: Record<string, string> = {
13
+ pistol: "手枪",
14
+ eco: "Eco",
15
+ semi: "半起",
16
+ force: "强起",
17
+ full: "长枪",
18
+ conversion: "长枪",
19
+ };
20
+
21
+ /**
22
+ * 经济类型中文标签(转化率面板 / 榜单展示用)。
23
+ * 统一了 RivalHub `economy-series.ts` 的 `economyLabelCn`。未知值原样返回。
24
+ */
25
+ export function economyLabelCn(type: string | null | undefined): string {
26
+ if (!type) return "";
27
+ return ECONOMY_LABELS_CN[type.toLowerCase()] ?? type;
28
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import { analyzeDemoPackage, loadDemoPackageFromZip } from "@cs2dak/core";
5
+ import { buildDemoViewModel, buildMatchWorkspaceModel } from "./index";
6
+
7
+ describe("@cs2dak/presentation", () => {
8
+ it("builds view and workspace models from canonical core analysis", async () => {
9
+ const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
10
+ const pkg = await loadDemoPackageFromZip(zip);
11
+ const view = buildDemoViewModel(analyzeDemoPackage(pkg));
12
+ const workspace = buildMatchWorkspaceModel(pkg);
13
+
14
+ expect(view.scoreline).toBe("3:13");
15
+ expect(workspace.title).toBe("她还爱我对不 队 vs 車一进一宝贝队");
16
+ expect(workspace.rounds).toHaveLength(16);
17
+ expect(workspace.replay.available).toBe(true);
18
+ });
19
+ });
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { buildDemoViewModel, buildMatchWorkspaceModel } from "./workspace.js";
2
+ export { buildSeasonLeaderboardModel } from "./leaderboard.js";
3
+ export { buildPlayerSeasonProfile, buildAllPlayerSeasonProfiles } from "./player.js";
4
+ export { buildTeamCohortSummary } from "./team.js";
5
+ export { buildSeriesSummary, recommendMatchMvp } from "./series.js";
6
+ export { SEASON_STAT_VIEWS } from "./season-metrics.js";
7
+ export { displayWeaponName } from "./weapons.js";
8
+ export { economyLabelCn, ECONOMY_LABEL_SHORT } from "./economy.js";
9
+ export { sideLabel } from "./labels.js";
10
+ export type { EconomyConversion, EconomyTypeStats, MatchEconomyConversion } from "@cs2dak/core";
package/src/labels.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function sideLabel(side: string): string {
2
+ return side === "t" ? "进攻方" : "防守方";
3
+ }
@@ -0,0 +1,84 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { loadDemoPackageFromZip } from "@cs2dak/core";
6
+ import { buildSeasonCohort } from "@cs2dak/cohort";
7
+ import { leaderboardMetricKeySchema, seasonLeaderboardModelSchema } from "@cs2dak/contract";
8
+ import { buildSeasonLeaderboardModel } from "./index";
9
+
10
+ const fixtureDir = fileURLToPath(new URL("../../../fixtures/input/cohort", import.meta.url));
11
+ const integrationTimeoutMs = 20_000;
12
+
13
+ const ALL_METRIC_KEYS = leaderboardMetricKeySchema.options;
14
+
15
+ async function buildCohort() {
16
+ const names = (await readdir(fixtureDir)).filter((name) => name.endsWith(".zip")).sort();
17
+ const demos = await Promise.all(
18
+ names.map(async (name) => ({
19
+ matchId: name.replace(/\.zip$/, ""),
20
+ pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
21
+ }))
22
+ );
23
+ return buildSeasonCohort(demos);
24
+ }
25
+
26
+ describe("buildSeasonLeaderboardModel", () => {
27
+ it(
28
+ "derives a product-neutral leaderboard model from a season cohort bundle",
29
+ async () => {
30
+ const bundle = await buildCohort();
31
+ const model = buildSeasonLeaderboardModel(bundle);
32
+
33
+ // schema 自校验 + 元信息透传
34
+ expect(() => seasonLeaderboardModelSchema.parse(model)).not.toThrow();
35
+ expect(model.version).toBe("cs2-demo-analysis-kit/leaderboard-0.1");
36
+ expect(model.matchCount).toBe(bundle.matchCount);
37
+ expect(model.weightsVersion).toBe(bundle.weightsVersion);
38
+ expect(model.provenance).toEqual(bundle.provenance);
39
+
40
+ // 视图:core/impact/advanced,无独立 Demo tab
41
+ expect(model.views.map((v) => v.key)).toEqual(["core", "impact", "advanced"]);
42
+ for (const view of model.views) {
43
+ // 默认排序列必须真实存在于该视图
44
+ expect(view.columns.some((c) => c.key === view.defaultSort)).toBe(true);
45
+ }
46
+
47
+ // 每行覆盖全部玩家,且每个指标 key 都有条目(可为 null,但不缺)
48
+ expect(model.rows).toHaveLength(bundle.players.length);
49
+ for (const row of model.rows) {
50
+ for (const key of ALL_METRIC_KEYS) {
51
+ expect(row.metrics).toHaveProperty(key);
52
+ }
53
+ // 中立性:不暴露数据库/路由字段,只有中立 key
54
+ expect(row).not.toHaveProperty("userId");
55
+ }
56
+ },
57
+ integrationTimeoutMs
58
+ );
59
+
60
+ it(
61
+ "preserves null instead of coercing to 0, and matches cohort source values",
62
+ async () => {
63
+ const bundle = await buildCohort();
64
+ const model = buildSeasonLeaderboardModel(bundle);
65
+
66
+ const byKey = new Map(model.rows.map((row) => [row.playerKey, row]));
67
+ for (const player of bundle.players) {
68
+ const row = byKey.get(player.playerKey)!;
69
+ // 不重算:rate 字段直接透传 cohort 已重算的值
70
+ expect(row.metrics.rivalhubRR).toBe(player.accountRR);
71
+ expect(row.metrics.hltvRating).toBe(player.rrV1);
72
+ expect(row.metrics.adr).toBe(player.indicators.adr);
73
+ expect(row.metrics.kast).toBe(player.indicators.kast);
74
+ // K/D:deaths=0 必须为 null 而非 0/Infinity
75
+ if (player.indicators.deaths === 0) {
76
+ expect(row.metrics.kd).toBeNull();
77
+ } else {
78
+ expect(row.metrics.kd).toBeCloseTo(player.indicators.kills / player.indicators.deaths, 1);
79
+ }
80
+ }
81
+ },
82
+ integrationTimeoutMs
83
+ );
84
+ });
@@ -0,0 +1,33 @@
1
+ import {
2
+ seasonLeaderboardModelSchema,
3
+ type SeasonCohortBundle,
4
+ type SeasonLeaderboardModel
5
+ } from "@cs2dak/contract";
6
+ import { SEASON_STAT_VIEWS, computeSeasonMetrics } from "./season-metrics.js";
7
+
8
+ /**
9
+ * 把赛季 cohort 结果转换为产品中立的排行榜展示模型。
10
+ * 纯转换:不重算聚合(cohort 已做 sum counts → recompute rates),不算评分公式。
11
+ */
12
+ export function buildSeasonLeaderboardModel(bundle: SeasonCohortBundle): SeasonLeaderboardModel {
13
+ const rows = bundle.players.map((player) => ({
14
+ playerKey: player.playerKey,
15
+ name: player.name,
16
+ steamIds: player.steamIds,
17
+ externalUserId: player.externalUserId,
18
+ teamKeys: player.teamKeys,
19
+ mapCount: player.mapCount,
20
+ confidence: player.confidence,
21
+ metrics: computeSeasonMetrics(player),
22
+ prism: player.prism
23
+ }));
24
+
25
+ return seasonLeaderboardModelSchema.parse({
26
+ version: "cs2-demo-analysis-kit/leaderboard-0.1",
27
+ weightsVersion: bundle.weightsVersion,
28
+ matchCount: bundle.matchCount,
29
+ provenance: bundle.provenance,
30
+ views: SEASON_STAT_VIEWS,
31
+ rows
32
+ });
33
+ }
@@ -0,0 +1,116 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { loadDemoPackageFromZip } from "@cs2dak/core";
6
+ import { buildSeasonCohort } from "@cs2dak/cohort";
7
+ import { playerSeasonProfileSchema } from "@cs2dak/contract";
8
+ import { buildAllPlayerSeasonProfiles, buildPlayerSeasonProfile } from "./index";
9
+
10
+ const fixtureDir = fileURLToPath(new URL("../../../fixtures/input/cohort", import.meta.url));
11
+ const integrationTimeoutMs = 20_000;
12
+ let cohortFixtures: ReturnType<typeof buildCohort> | null = null;
13
+
14
+ async function buildCohort() {
15
+ const names = (await readdir(fixtureDir)).filter((name) => name.endsWith(".zip")).sort();
16
+ const demos = await Promise.all(
17
+ names.map(async (name) => ({
18
+ matchId: name.replace(/\.zip$/, ""),
19
+ pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
20
+ }))
21
+ );
22
+ return buildSeasonCohort(demos);
23
+ }
24
+
25
+ function getCohort() {
26
+ cohortFixtures ??= buildCohort();
27
+ return cohortFixtures;
28
+ }
29
+
30
+ describe("buildPlayerSeasonProfile", () => {
31
+ it(
32
+ "derives a per-player profile covering rating, metrics, style and trend",
33
+ async () => {
34
+ const bundle = await getCohort();
35
+ const profiles = buildAllPlayerSeasonProfiles(bundle);
36
+
37
+ expect(profiles).toHaveLength(bundle.players.length);
38
+ for (const profile of profiles) {
39
+ expect(() => playerSeasonProfileSchema.parse(profile)).not.toThrow();
40
+ }
41
+
42
+ const source = bundle.players[0];
43
+ const profile = buildPlayerSeasonProfile(bundle, source.playerKey);
44
+
45
+ // 元信息与评分透传(不重算)
46
+ expect(profile.version).toBe("cs2-demo-analysis-kit/player-profile-0.1");
47
+ expect(profile.rating.rivalhubRR).toBe(source.accountRR);
48
+ expect(profile.rating.hltvRating).toBe(source.rrV1);
49
+ expect(profile.rating.hltvPercentile).toBe(source.rrV1Percentile);
50
+ expect(profile.rating.breakdown.map((b) => b.key)).toEqual([
51
+ "combat",
52
+ "trade",
53
+ "clutch",
54
+ "objective",
55
+ "utility"
56
+ ]);
57
+ expect(profile.weapons.reduce((sum, weapon) => sum + weapon.kills, 0)).toBe(source.weaponHighlights.totalKills);
58
+ expect(profile.weapons.map((weapon) => weapon.kills)).toEqual(
59
+ [...profile.weapons.map((weapon) => weapon.kills)].sort((a, b) => b - a)
60
+ );
61
+ expect(profile.weapons.every((weapon) =>
62
+ weapon.headshotPercent == null || (weapon.headshotPercent >= 0 && weapon.headshotPercent <= 100)
63
+ )).toBe(true);
64
+ expect(profile.highlights.noScopeKills).toBe(source.weaponHighlights.highlights.noScopeKills);
65
+ expect(profile.highlights.wallbangKills).toBe(source.weaponHighlights.highlights.wallbangKills);
66
+
67
+ // 每场趋势条数 == 该选手参与场次,且按 matchId 升序
68
+ expect(profile.perMatch).toHaveLength(source.perMatch.length);
69
+ const ids = profile.perMatch.map((m) => m.matchId);
70
+ expect(ids).toEqual([...ids].sort((a, b) => a.localeCompare(b)));
71
+
72
+ // 中立性:不暴露数据库/路由字段
73
+ expect(profile).not.toHaveProperty("userId");
74
+ },
75
+ integrationTimeoutMs
76
+ );
77
+
78
+ it(
79
+ "exposes PRISM style as 8 ordered axes, or null when PRISM is missing",
80
+ async () => {
81
+ const bundle = await getCohort();
82
+ const profiles = buildAllPlayerSeasonProfiles(bundle);
83
+
84
+ for (const profile of profiles) {
85
+ const source = bundle.players.find((p) => p.playerKey === profile.playerKey)!;
86
+ if (source.prism == null) {
87
+ expect(profile.style).toBeNull();
88
+ } else {
89
+ expect(profile.style).not.toBeNull();
90
+ expect(profile.style!.axes).toHaveLength(8);
91
+ // 风格轴顺序固定(PRISM_AXIS_ORDER)
92
+ expect(profile.style!.axes.map((a) => a.key)).toEqual([
93
+ "firepower",
94
+ "opening",
95
+ "clutch",
96
+ "sniping",
97
+ "survival",
98
+ "utility",
99
+ "trading",
100
+ "entry"
101
+ ]);
102
+ for (const axis of profile.style!.axes) {
103
+ expect(axis.percentile).toBeGreaterThanOrEqual(0);
104
+ expect(axis.percentile).toBeLessThanOrEqual(100);
105
+ }
106
+ }
107
+ }
108
+ },
109
+ integrationTimeoutMs
110
+ );
111
+
112
+ it("throws for an unknown playerKey", async () => {
113
+ const bundle = await getCohort();
114
+ expect(() => buildPlayerSeasonProfile(bundle, "steam:does-not-exist")).toThrow(/not found/);
115
+ }, integrationTimeoutMs);
116
+ });
package/src/player.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { PRISM_AXIS_ORDER, type PrismAxisKey } from "@rivalhub/rival-rating";
2
+ import {
3
+ playerSeasonProfileSchema,
4
+ type LeaderboardMetricKey,
5
+ type PlayerSeasonProfile,
6
+ type RRBreakdownEntry,
7
+ type SeasonCohortBundle,
8
+ type SeasonPlayerRow
9
+ } from "@cs2dak/contract";
10
+ import { computeSeasonMetrics, round } from "./season-metrics.js";
11
+ import { displayWeaponName } from "./weapons.js";
12
+
13
+ /** RR 账户分解标签(与 Match Workspace 的 rrBreakdown 一致)。 */
14
+ const RR_BREAKDOWN_LABEL: Record<RRBreakdownEntry["key"], string> = {
15
+ combat: "Combat",
16
+ trade: "Trade",
17
+ clutch: "Clutch",
18
+ objective: "Objective",
19
+ utility: "Utility"
20
+ };
21
+
22
+ /** PRISM 八维中文标签。 */
23
+ const PRISM_AXIS_LABEL: Record<PrismAxisKey, string> = {
24
+ firepower: "火力",
25
+ opening: "首杀",
26
+ clutch: "残局",
27
+ sniping: "狙击",
28
+ survival: "生存",
29
+ utility: "道具",
30
+ trading: "补枪",
31
+ entry: "突破"
32
+ };
33
+
34
+ /** 用于强项/弱项判定的技能类指标(高 = 好)。不含纯风格标签(如 AWP)。 */
35
+ const SKILL_METRICS: { key: LeaderboardMetricKey; label: string }[] = [
36
+ { key: "adr", label: "输出 (ADR)" },
37
+ { key: "kast", label: "回合参与 (KAST)" },
38
+ { key: "kd", label: "对枪交换 (K/D)" },
39
+ { key: "hsPercent", label: "爆头率" },
40
+ { key: "openingDuelWinRate", label: "首杀对枪 (Entry)" },
41
+ { key: "multiKillPer100", label: "多杀产量" }
42
+ ];
43
+
44
+ const STRENGTH_PERCENTILE = 70;
45
+ const WEAKNESS_PERCENTILE = 30;
46
+ const MIN_COHORT_FOR_NARRATIVE = 5;
47
+ const MAX_NARRATIVE_ITEMS = 3;
48
+
49
+ /** value 在分布中的百分位(≤ 计数 / 总数 × 100)。 */
50
+ function percentileOf(value: number, distribution: number[]): number {
51
+ if (distribution.length === 0) return 0;
52
+ const atOrBelow = distribution.filter((v) => v <= value).length;
53
+ return round((atOrBelow / distribution.length) * 100, 1);
54
+ }
55
+
56
+ function buildStyle(player: SeasonPlayerRow): PlayerSeasonProfile["style"] {
57
+ const prism = player.prism;
58
+ if (!prism) return null;
59
+ return {
60
+ weightsVersion: prism.weightsVersion,
61
+ rrPercentile: prism.rrPercentile,
62
+ axes: PRISM_AXIS_ORDER.map((key) => ({
63
+ key,
64
+ label: PRISM_AXIS_LABEL[key],
65
+ percentile: prism.axes[key].percentile
66
+ }))
67
+ };
68
+ }
69
+
70
+ function profileFromRow(
71
+ player: SeasonPlayerRow,
72
+ metrics: Record<LeaderboardMetricKey, number | null>,
73
+ weightsVersion: string,
74
+ strengths: string[],
75
+ weaknesses: string[]
76
+ ): PlayerSeasonProfile {
77
+ const percent = (count: number, total: number): number | null =>
78
+ total > 0 ? round((count / total) * 100, 1) : null;
79
+
80
+ return playerSeasonProfileSchema.parse({
81
+ version: "cs2-demo-analysis-kit/player-profile-0.1",
82
+ weightsVersion,
83
+ playerKey: player.playerKey,
84
+ name: player.name,
85
+ steamIds: player.steamIds,
86
+ externalUserId: player.externalUserId,
87
+ teamKeys: player.teamKeys,
88
+ mapCount: player.mapCount,
89
+ confidence: player.confidence,
90
+ accountContextStatus: player.accountContextStatus,
91
+ rating: {
92
+ rivalhubRR: player.accountRR,
93
+ rivalhubRRRaw: player.accountRRRaw,
94
+ hltvRating: player.rrV1,
95
+ hltvPercentile: player.rrV1Percentile,
96
+ breakdown: (Object.keys(RR_BREAKDOWN_LABEL) as RRBreakdownEntry["key"][]).map((key) => ({
97
+ key,
98
+ label: RR_BREAKDOWN_LABEL[key],
99
+ value: player.accountBreakdown[key]
100
+ }))
101
+ },
102
+ metrics,
103
+ weapons: player.weaponHighlights.weapons.map((weapon) => ({
104
+ weapon: weapon.weapon,
105
+ label: displayWeaponName(weapon.weapon),
106
+ kills: weapon.kills,
107
+ killSharePercent: player.weaponHighlights.totalKills > 0
108
+ ? round((weapon.kills / player.weaponHighlights.totalKills) * 100, 1)
109
+ : 0,
110
+ headshotPercent: percent(weapon.headshotKills, weapon.kills),
111
+ tradeKillPercent: percent(weapon.tradeKills, weapon.kills),
112
+ noScopePercent: percent(weapon.noScopeKills, weapon.kills),
113
+ throughSmokePercent: percent(weapon.throughSmokeKills, weapon.kills),
114
+ wallbangPercent: percent(weapon.wallbangKills, weapon.kills),
115
+ averagePenetratedObjects: weapon.kills > 0
116
+ ? round(weapon.penetratedObjects / weapon.kills, 2)
117
+ : null
118
+ })),
119
+ highlights: player.weaponHighlights.highlights,
120
+ style: buildStyle(player),
121
+ perMatch: [...player.perMatch]
122
+ .sort((a, b) => a.matchId.localeCompare(b.matchId))
123
+ .map((m) => ({ matchId: m.matchId, rivalhubRR: m.accountRR, hltvRating: m.rrV1 })),
124
+ strengths,
125
+ weaknesses
126
+ });
127
+ }
128
+
129
+ /**
130
+ * 为赛季 cohort 中每个选手派生档案。强项/弱项相对该 cohort 计算(技能类指标百分位)。
131
+ * 纯转换 + cohort 内部排名总结,不算评分公式。
132
+ */
133
+ export function buildAllPlayerSeasonProfiles(bundle: SeasonCohortBundle): PlayerSeasonProfile[] {
134
+ const metricsByKey = new Map(bundle.players.map((p) => [p.playerKey, computeSeasonMetrics(p)]));
135
+
136
+ // 每个技能指标在 cohort 内的取值分布(剔除 null)。
137
+ const distribution = new Map<LeaderboardMetricKey, number[]>(
138
+ SKILL_METRICS.map(({ key }) => [
139
+ key,
140
+ bundle.players
141
+ .map((p) => metricsByKey.get(p.playerKey)![key])
142
+ .filter((v): v is number => v != null)
143
+ ])
144
+ );
145
+
146
+ const cohortLargeEnough = bundle.players.length >= MIN_COHORT_FOR_NARRATIVE;
147
+
148
+ return bundle.players.map((player) => {
149
+ const metrics = metricsByKey.get(player.playerKey)!;
150
+
151
+ const ranked = cohortLargeEnough
152
+ ? SKILL_METRICS.map(({ key, label }) => {
153
+ const value = metrics[key];
154
+ if (value == null) return null;
155
+ return { label, percentile: percentileOf(value, distribution.get(key)!) };
156
+ }).filter((x): x is { label: string; percentile: number } => x != null)
157
+ : [];
158
+
159
+ const strengths = ranked
160
+ .filter((r) => r.percentile >= STRENGTH_PERCENTILE)
161
+ .sort((a, b) => b.percentile - a.percentile)
162
+ .slice(0, MAX_NARRATIVE_ITEMS)
163
+ .map((r) => r.label);
164
+ const weaknesses = ranked
165
+ .filter((r) => r.percentile <= WEAKNESS_PERCENTILE)
166
+ .sort((a, b) => a.percentile - b.percentile)
167
+ .slice(0, MAX_NARRATIVE_ITEMS)
168
+ .map((r) => r.label);
169
+
170
+ return profileFromRow(player, metrics, bundle.weightsVersion, strengths, weaknesses);
171
+ });
172
+ }
173
+
174
+ /** 取单个选手档案;playerKey 不存在时抛错。 */
175
+ export function buildPlayerSeasonProfile(bundle: SeasonCohortBundle, playerKey: string): PlayerSeasonProfile {
176
+ const profile = buildAllPlayerSeasonProfiles(bundle).find((p) => p.playerKey === playerKey);
177
+ if (!profile) {
178
+ throw new Error(`playerKey not found in cohort: ${playerKey}`);
179
+ }
180
+ return profile;
181
+ }
@@ -0,0 +1,93 @@
1
+ import type { LeaderboardMetricKey, LeaderboardView, SeasonPlayerRow } from "@cs2dak/contract";
2
+
3
+ /**
4
+ * 赛季统计的共享展示层:指标计算 + 视图(列)定义。
5
+ * 排行榜(SeasonLeaderboardModel)和选手页(PlayerSeasonProfile)共用同一套,
6
+ * 保证两处的口径、标签、格式一致。标签归 presentation(React 不重复定义)。
7
+ *
8
+ * 约定:builder 把所有指标归一化到“展示刻度”,format 只负责精度与单位后缀:
9
+ * - 百分比统一为 0–100;产量家族(FK/MK/C)统一为每 100 回合。
10
+ * - 缺失保持 null,不伪造 0。
11
+ */
12
+
13
+ export function round(value: number, digits = 2): number {
14
+ const factor = 10 ** digits;
15
+ return Math.round(value * factor) / factor;
16
+ }
17
+
18
+ /** 视图列定义(信息架构来自 RivalHub StatsLeaderboard,去掉 OCR 副指标与独立 Demo tab)。 */
19
+ export const SEASON_STAT_VIEWS: LeaderboardView[] = [
20
+ {
21
+ key: "core",
22
+ label: "Core",
23
+ defaultSort: "rivalhubRR",
24
+ columns: [
25
+ { key: "maps", label: "Maps", format: "integer", description: null },
26
+ { key: "rivalhubRR", label: "RR", format: "rating", description: "RivalHub RR(绝对刻度评分)" },
27
+ { key: "hltvRating", label: "Rating 2.0", format: "rating", description: "HLTV Rating 2.0 量纲" },
28
+ { key: "adr", label: "ADR", format: "adr", description: "每回合平均伤害" },
29
+ { key: "kd", label: "K/D", format: "ratio", description: "击杀死亡比" },
30
+ { key: "kpr", label: "KPR", format: "ratio", description: "每回合击杀" },
31
+ { key: "hsPercent", label: "HS%", format: "percent", description: "爆头率" }
32
+ ]
33
+ },
34
+ {
35
+ key: "impact",
36
+ label: "Impact",
37
+ defaultSort: "firstKillPer100",
38
+ columns: [
39
+ { key: "maps", label: "Maps", format: "integer", description: null },
40
+ { key: "rivalhubRR", label: "RR", format: "rating", description: "RivalHub RR(绝对刻度评分)" },
41
+ { key: "hltvRating", label: "Rating 2.0", format: "rating", description: "HLTV Rating 2.0 量纲" },
42
+ { key: "firstKillPer100", label: "FK/100r", format: "ratio", description: "每 100 回合首杀" },
43
+ { key: "multiKillPer100", label: "MK/100r", format: "ratio", description: "每 100 回合多杀回合" },
44
+ { key: "clutchPer100", label: "C/100r", format: "ratio", description: "每 100 回合残局胜利" },
45
+ { key: "openingDuelWinRate", label: "Entry%", format: "percent", description: "首杀对枪胜率" }
46
+ ]
47
+ },
48
+ {
49
+ key: "advanced",
50
+ label: "Advanced",
51
+ defaultSort: "kast",
52
+ columns: [
53
+ { key: "maps", label: "Maps", format: "integer", description: null },
54
+ { key: "rivalhubRR", label: "RR", format: "rating", description: "RivalHub RR(绝对刻度评分)" },
55
+ { key: "hltvRating", label: "Rating 2.0", format: "rating", description: "HLTV Rating 2.0 量纲" },
56
+ { key: "kast", label: "KAST%", format: "percent", description: "有效回合参与率" },
57
+ { key: "utilityDamagePerRound", label: "Util/R", format: "ratio", description: "每回合道具伤害" },
58
+ { key: "awpKillsPerRound", label: "AWP/R", format: "ratio", description: "每回合 AWP 击杀" },
59
+ { key: "awpKillRate", label: "AWP%", format: "percent", description: "AWP 击杀占比" },
60
+ { key: "tradeKillRate", label: "Trade/R", format: "ratio", description: "每回合补枪击杀" },
61
+ { key: "flashAssistPerRound", label: "FA/R", format: "ratio", description: "每回合闪光助攻" }
62
+ ]
63
+ }
64
+ ];
65
+
66
+ /**
67
+ * 把一行赛季 cohort 结果转换为展示指标。纯转换:不重算聚合(cohort 已 sum→recompute),
68
+ * 不算评分公式。缺失保持 null。
69
+ */
70
+ export function computeSeasonMetrics(player: SeasonPlayerRow): Record<LeaderboardMetricKey, number | null> {
71
+ const ind = player.indicators;
72
+ return {
73
+ maps: player.mapCount,
74
+ rivalhubRR: player.accountRR,
75
+ hltvRating: player.rrV1,
76
+ adr: ind.adr,
77
+ kd: ind.deaths > 0 ? round(ind.kills / ind.deaths, 2) : null,
78
+ kpr: ind.kpr,
79
+ hsPercent: ind.hsPercent, // 已是 0–100
80
+ // 产量家族:每 100 回合 X 次
81
+ firstKillPer100: round(ind.firstKillRate * 100, 2),
82
+ multiKillPer100: round(ind.multiKillRate * 100, 2),
83
+ clutchPer100: round((ind.clutchWins / ind.totalRounds) * 100, 2),
84
+ // 百分比统一为 0–100(cohort 中以 0–1 存储的需 ×100)
85
+ openingDuelWinRate: round(ind.openingDuelWinRate * 100, 2),
86
+ kast: ind.kast, // 已是 0–100
87
+ utilityDamagePerRound: ind.utilityDamagePerRound,
88
+ awpKillsPerRound: ind.awpKillsPerRound,
89
+ awpKillRate: round(ind.awpKillRate * 100, 2),
90
+ flashAssistPerRound: ind.flashAssistPerRound,
91
+ tradeKillRate: ind.tradeKillRate
92
+ };
93
+ }