@cs2dak/presentation 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,116 +1,145 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import { describe, expect, it } from "vitest";
5
- import { loadDemoPackageFromZip } from "@cs2dak/core";
6
- import { buildSeasonCohort } from "@cs2dak/cohort";
7
2
  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
- }
3
+ import { buildAllPlayerSeasonProfiles } from "./index";
4
+ import { buildTestSeasonCohortBundle } from "./test-fixtures";
5
+
6
+ describe("buildAllPlayerSeasonProfiles", () => {
7
+ it("derives a per-player profile covering rating, metrics, style and trend", () => {
8
+ const bundle = buildTestSeasonCohortBundle();
9
+ const profiles = buildAllPlayerSeasonProfiles(bundle);
10
+
11
+ expect(profiles).toHaveLength(bundle.players.length);
12
+ for (const profile of profiles) {
13
+ expect(() => playerSeasonProfileSchema.parse(profile)).not.toThrow();
14
+ }
15
+
16
+ const source = bundle.players[0];
17
+ const profile = profiles.find((row) => row.playerKey === source.playerKey)!;
18
+
19
+ // 元信息与评分透传(不重算)
20
+ expect(profile.version).toBe("cs2-demo-analysis-kit/player-profile-0.2");
21
+ expect(profile.rating.rivalhubRR).toBe(source.accountRR);
22
+ expect(profile.rating.hltvRating).toBe(source.rrV1);
23
+ expect(profile.rating.hltvPercentile).toBe(source.rrV1Percentile);
24
+ expect(profile.rating.breakdown.map((b) => b.key)).toEqual([
25
+ "combat",
26
+ "trade",
27
+ "mapControl",
28
+ "clutch",
29
+ "objective",
30
+ "utility"
31
+ ]);
32
+ expect(profile.weapons.reduce((sum, weapon) => sum + weapon.kills, 0)).toBe(source.weaponHighlights.totalKills);
33
+ expect(profile.weapons.map((weapon) => weapon.kills)).toEqual(
34
+ [...profile.weapons.map((weapon) => weapon.kills)].sort((a, b) => b - a)
35
+ );
36
+ expect(profile.weapons.every((weapon) =>
37
+ weapon.headshotPercent == null || (weapon.headshotPercent >= 0 && weapon.headshotPercent <= 100)
38
+ )).toBe(true);
39
+ expect(profile.highlights.noScopeKills).toBe(source.weaponHighlights.highlights.noScopeKills);
40
+ expect(profile.highlights.wallbangKills).toBe(source.weaponHighlights.highlights.wallbangKills);
41
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
- }
42
+ // 每场趋势条数 == 该选手参与场次,且按 matchId 升序
43
+ expect(profile.perMatch).toHaveLength(source.perMatch.length);
44
+ const ids = profile.perMatch.map((m) => m.matchId);
45
+ expect(ids).toEqual([...ids].sort((a, b) => a.localeCompare(b)));
46
+
47
+ // 中立性:不暴露数据库/路由字段
48
+ expect(profile).not.toHaveProperty("userId");
49
+ });
50
+
51
+ it("exposes PRISM style as 8 ordered axes, or null when PRISM is missing", () => {
52
+ const bundle = buildTestSeasonCohortBundle();
53
+ const profiles = buildAllPlayerSeasonProfiles(bundle);
54
+
55
+ for (const profile of profiles) {
56
+ const source = bundle.players.find((p) => p.playerKey === profile.playerKey)!;
57
+ if (source.prism == null) {
58
+ expect(profile.style).toBeNull();
59
+ } else {
60
+ expect(profile.style).not.toBeNull();
61
+ expect(profile.style!.axes).toHaveLength(8);
62
+ // 风格轴顺序固定(PRISM_AXIS_ORDER)
63
+ expect(profile.style!.axes.map((a) => a.key)).toEqual([
64
+ "firepower",
65
+ "opening",
66
+ "clutch",
67
+ "sniping",
68
+ "survival",
69
+ "utility",
70
+ "trading",
71
+ "entry"
72
+ ]);
73
+ for (const axis of profile.style!.axes) {
74
+ expect(axis.status).toBe("ready");
75
+ expect(axis.involvementPercentile).toBeGreaterThanOrEqual(0);
76
+ expect(axis.involvementPercentile).toBeLessThanOrEqual(100);
77
+ expect(axis.efficiencyPercentile).toBeGreaterThanOrEqual(0);
78
+ expect(axis.efficiencyPercentile).toBeLessThanOrEqual(100);
79
+ expect(axis.signalCoverage).toBe(1);
80
+ expect(axis.comparisonCount).toBe(5);
106
81
  }
107
82
  }
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);
83
+ }
84
+ });
85
+
86
+ it("does not expose precise axis percentiles when PRISM signal coverage is partial", () => {
87
+ const bundle = buildTestSeasonCohortBundle();
88
+ const source = bundle.players.find((player) => player.prism != null)!;
89
+ source.prism!.axes.entry.availableSignalWeight = 0.5;
90
+
91
+ const profile = buildAllPlayerSeasonProfiles(bundle).find((item) => item.playerKey === source.playerKey)!;
92
+ const entry = profile.style!.axes.find((axis) => axis.key === "entry")!;
93
+
94
+ expect(entry.status).toBe("partial");
95
+ expect(entry.involvementPercentile).toBeNull();
96
+ expect(entry.efficiencyPercentile).toBeNull();
97
+ expect(entry.combinedPercentile).toBeNull();
98
+ });
99
+
100
+ it("does not expose precise PRISM percentiles for a one-player cohort", () => {
101
+ const bundle = buildTestSeasonCohortBundle();
102
+ bundle.players = bundle.players.slice(0, 1);
103
+
104
+ const profile = buildAllPlayerSeasonProfiles(bundle)[0];
105
+ const axis = profile.style!.axes.find((item) => item.key === "firepower")!;
106
+
107
+ expect(axis.status).toBe("insufficient");
108
+ expect(axis.comparisonCount).toBe(1);
109
+ expect(axis.involvementPercentile).toBeNull();
110
+ expect(axis.efficiencyPercentile).toBeNull();
111
+ expect(axis.combinedPercentile).toBeNull();
112
+ });
113
+
114
+ it("does not expose precise PRISM percentiles for a small cohort", () => {
115
+ const bundle = buildTestSeasonCohortBundle();
116
+ bundle.players = bundle.players.slice(0, 2);
117
+
118
+ const profile = buildAllPlayerSeasonProfiles(bundle)[0];
119
+ const axis = profile.style!.axes.find((item) => item.key === "firepower")!;
120
+
121
+ expect(axis.status).toBe("insufficient");
122
+ expect(axis.comparisonCount).toBe(2);
123
+ expect(axis.involvementPercentile).toBeNull();
124
+ expect(axis.efficiencyPercentile).toBeNull();
125
+ });
126
+
127
+ it("uses midpoint ranks when all valid PRISM values tie", () => {
128
+ const bundle = buildTestSeasonCohortBundle();
129
+ for (const player of bundle.players) {
130
+ if (!player.prism) continue;
131
+ player.prism.axes.sniping.involvementRaw = 4;
132
+ player.prism.axes.sniping.efficiencyRaw = 2;
133
+ }
134
+
135
+ const axes = buildAllPlayerSeasonProfiles(bundle)
136
+ .flatMap((profile) => profile.style?.axes.filter((axis) => axis.key === "sniping") ?? []);
137
+
138
+ expect(axes).toHaveLength(5);
139
+ for (const axis of axes) {
140
+ expect(axis.status).toBe("ready");
141
+ expect(axis.involvementPercentile).toBe(50);
142
+ expect(axis.efficiencyPercentile).toBe(50);
143
+ }
144
+ });
116
145
  });
package/src/player.ts CHANGED
@@ -14,23 +14,28 @@ import { displayWeaponName } from "./weapons.js";
14
14
  const RR_BREAKDOWN_LABEL: Record<RRBreakdownEntry["key"], string> = {
15
15
  combat: "Combat",
16
16
  trade: "Trade",
17
+ mapControl: "MapControl",
17
18
  clutch: "Clutch",
18
19
  objective: "Objective",
19
20
  utility: "Utility"
20
21
  };
21
22
 
22
- /** PRISM 八维中文标签。 */
23
+ /** PRISM 八维打法画像标签;不把单根轴命名成绝对能力或固定角色。 */
23
24
  const PRISM_AXIS_LABEL: Record<PrismAxisKey, string> = {
24
- firepower: "火力",
25
- opening: "首杀",
26
- clutch: "残局",
27
- sniping: "狙击",
28
- survival: "生存",
29
- utility: "道具",
30
- trading: "补枪",
31
- entry: "突破"
25
+ firepower: "火力输出",
26
+ opening: "开局对枪",
27
+ clutch: "残局参与",
28
+ sniping: "AWP 使用",
29
+ survival: "生存选择",
30
+ utility: "道具投入",
31
+ trading: "补枪协同",
32
+ entry: "突破参与"
32
33
  };
33
34
 
35
+ const MIN_STYLE_SIGNAL_COVERAGE = 0.75;
36
+ /** 小于此人数的 cohort 只能用于浏览原始数据,不能给出精确的相对排名。 */
37
+ const MIN_COHORT_FOR_STYLE_PERCENTILE = 5;
38
+
34
39
  /** 用于强项/弱项判定的技能类指标(高 = 好)。不含纯风格标签(如 AWP)。 */
35
40
  const SKILL_METRICS: { key: LeaderboardMetricKey; label: string }[] = [
36
41
  { key: "adr", label: "输出 (ADR)" },
@@ -53,17 +58,47 @@ function percentileOf(value: number, distribution: number[]): number {
53
58
  return round((atOrBelow / distribution.length) * 100, 1);
54
59
  }
55
60
 
56
- function buildStyle(player: SeasonPlayerRow): PlayerSeasonProfile["style"] {
61
+ type PrismDistributions = Record<PrismAxisKey, { involvement: number[]; efficiency: number[] }>;
62
+
63
+ /** 并列值取中位秩;全员相同时返回 P50,避免小 cohort 中把并列都显示成 P100。 */
64
+ function relativePercentileOf(value: number, distribution: number[]): number | null {
65
+ if (distribution.length === 0) return null;
66
+ const lower = distribution.filter((item) => item < value).length;
67
+ const equal = distribution.filter((item) => item === value).length;
68
+ return round(((lower + equal / 2) / distribution.length) * 100, 1);
69
+ }
70
+
71
+ function buildStyle(player: SeasonPlayerRow, distributions: PrismDistributions): PlayerSeasonProfile["style"] {
57
72
  const prism = player.prism;
58
73
  if (!prism) return null;
59
74
  return {
60
75
  weightsVersion: prism.weightsVersion,
61
76
  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
- }))
77
+ axes: PRISM_AXIS_ORDER.map((key) => {
78
+ const axis = prism.axes[key];
79
+ const comparisonCount = distributions[key].involvement.length;
80
+ const status = !axis.hasSignal
81
+ ? "unavailable" as const
82
+ : axis.availableSignalWeight < MIN_STYLE_SIGNAL_COVERAGE
83
+ ? "partial" as const
84
+ : comparisonCount < MIN_COHORT_FOR_STYLE_PERCENTILE
85
+ ? "insufficient" as const
86
+ : "ready" as const;
87
+ return {
88
+ key,
89
+ label: PRISM_AXIS_LABEL[key],
90
+ involvementPercentile: status === "ready"
91
+ ? relativePercentileOf(axis.involvementRaw, distributions[key].involvement)
92
+ : null,
93
+ efficiencyPercentile: status === "ready"
94
+ ? relativePercentileOf(axis.efficiencyRaw, distributions[key].efficiency)
95
+ : null,
96
+ combinedPercentile: status === "ready" ? axis.percentile : null,
97
+ signalCoverage: axis.availableSignalWeight,
98
+ comparisonCount,
99
+ status
100
+ };
101
+ })
67
102
  };
68
103
  }
69
104
 
@@ -72,13 +107,14 @@ function profileFromRow(
72
107
  metrics: Record<LeaderboardMetricKey, number | null>,
73
108
  weightsVersion: string,
74
109
  strengths: string[],
75
- weaknesses: string[]
110
+ weaknesses: string[],
111
+ prismDistributions: PrismDistributions
76
112
  ): PlayerSeasonProfile {
77
113
  const percent = (count: number, total: number): number | null =>
78
114
  total > 0 ? round((count / total) * 100, 1) : null;
79
115
 
80
116
  return playerSeasonProfileSchema.parse({
81
- version: "cs2-demo-analysis-kit/player-profile-0.1",
117
+ version: "cs2-demo-analysis-kit/player-profile-0.2",
82
118
  weightsVersion,
83
119
  playerKey: player.playerKey,
84
120
  name: player.name,
@@ -117,7 +153,7 @@ function profileFromRow(
117
153
  : null
118
154
  })),
119
155
  highlights: player.weaponHighlights.highlights,
120
- style: buildStyle(player),
156
+ style: buildStyle(player, prismDistributions),
121
157
  perMatch: [...player.perMatch]
122
158
  .sort((a, b) => a.matchId.localeCompare(b.matchId))
123
159
  .map((m) => ({ matchId: m.matchId, rivalhubRR: m.accountRR, hltvRating: m.rrV1 })),
@@ -144,6 +180,17 @@ export function buildAllPlayerSeasonProfiles(bundle: SeasonCohortBundle): Player
144
180
  );
145
181
 
146
182
  const cohortLargeEnough = bundle.players.length >= MIN_COHORT_FOR_NARRATIVE;
183
+ const prismDistributions = Object.fromEntries(PRISM_AXIS_ORDER.map((key) => {
184
+ const usable = bundle.players
185
+ .map((player) => player.prism?.axes[key] ?? null)
186
+ .filter((axis): axis is NonNullable<typeof axis> =>
187
+ axis != null && axis.hasSignal && axis.availableSignalWeight >= MIN_STYLE_SIGNAL_COVERAGE
188
+ );
189
+ return [key, {
190
+ involvement: usable.map((axis) => axis.involvementRaw),
191
+ efficiency: usable.map((axis) => axis.efficiencyRaw)
192
+ }];
193
+ })) as PrismDistributions;
147
194
 
148
195
  return bundle.players.map((player) => {
149
196
  const metrics = metricsByKey.get(player.playerKey)!;
@@ -167,15 +214,6 @@ export function buildAllPlayerSeasonProfiles(bundle: SeasonCohortBundle): Player
167
214
  .slice(0, MAX_NARRATIVE_ITEMS)
168
215
  .map((r) => r.label);
169
216
 
170
- return profileFromRow(player, metrics, bundle.weightsVersion, strengths, weaknesses);
217
+ return profileFromRow(player, metrics, bundle.weightsVersion, strengths, weaknesses, prismDistributions);
171
218
  });
172
219
  }
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,121 @@
1
+ /**
2
+ * 雷达场渲染期合成 —— 把 RadarField 的原始计数归一化、按模式合成、可选差分。
3
+ *
4
+ * 纯函数,供 react 画布逐秒调用(scrubber)。底层基础场不动,所有视图都是这里的派生:
5
+ * ctVis/tVis = 4:3 屏幕可见;ctAim/tAim = 30°准星覆盖;
6
+ * ctPres/tPres = 位置占据;ctSound/tSound = 发声风险。
7
+ * info-diff = tVis − ctVis(T 信息优势暖 / CT 预警冷)
8
+ * contested = min(tVis, ctVis)(双方都看到 = 对拼线 / 真实交火点)
9
+ * 给定 baseline(联赛场)则输出「队伍 − 联赛」偏移(队伍倾向 / 防守漏洞)。
10
+ */
11
+ import type { RadarField, RadarFieldBase } from "@cs2dak/contract";
12
+
13
+ export type RadarFieldMode = RadarFieldBase | "info-diff" | "contested";
14
+
15
+ export interface RadarModeOption {
16
+ value: RadarFieldMode;
17
+ label: string;
18
+ }
19
+
20
+ /**
21
+ * 模式选项与中文标签(沿用现有 UI 文案:视野 / 位置 / 信息差分 / 对拼线)。
22
+ * 盲区不单列模式——画视野覆盖、空白即盲区(配合 canvas「照亮模式」读负空间更直观)。
23
+ */
24
+ export const RADAR_FIELD_MODES: RadarModeOption[] = [
25
+ { value: "ctVis", label: "CT 屏幕可见" },
26
+ { value: "tVis", label: "T 屏幕可见" },
27
+ { value: "ctAim", label: "CT 准星覆盖" },
28
+ { value: "tAim", label: "T 准星覆盖" },
29
+ { value: "ctPres", label: "CT 位置" },
30
+ { value: "tPres", label: "T 位置" },
31
+ { value: "ctSound", label: "CT 发声风险" },
32
+ { value: "tSound", label: "T 发声风险" },
33
+ { value: "info-diff", label: "信息差分" },
34
+ { value: "contested", label: "对拼线" },
35
+ ];
36
+
37
+ /** 各模式的颜色强度归一化上限(与原型一致)。 */
38
+ const MODE_CAP: Record<RadarFieldMode, number> = {
39
+ ctVis: 0.5,
40
+ tVis: 0.5,
41
+ ctAim: 0.35,
42
+ tAim: 0.35,
43
+ ctPres: 0.3,
44
+ tPres: 0.3,
45
+ ctSound: 1,
46
+ tSound: 1,
47
+ "info-diff": 0.4,
48
+ contested: 0.3,
49
+ };
50
+ const DELTA_CAP = 0.15;
51
+
52
+ export interface RadarModeFrame {
53
+ /** 逐格渲染值。signed=false 时 ∈[0,cap],signed=true 时为带符号偏移。 */
54
+ values: Float64Array;
55
+ /** true → 发散色(差分 / info-diff:暖=正 / 冷=负);false → 顺序热力。 */
56
+ signed: boolean;
57
+ /** 颜色强度归一化上限。 */
58
+ cap: number;
59
+ }
60
+
61
+ /** 某基础场在 [sec−window, sec+window] 的归一化频率(计数 / 对应 side denom)。 */
62
+ function freqAt(field: RadarField, base: RadarFieldBase, sec: number, window: number): Float64Array {
63
+ const nCells = field.grid.cells.length;
64
+ const out = new Float64Array(nCells);
65
+ const denomArr = base.startsWith("ct") ? field.denomCt : field.denomT;
66
+ let denom = 0;
67
+ const lo = Math.max(0, sec - window);
68
+ const hi = Math.min(field.maxSec - 1, sec + window);
69
+ for (let s = lo; s <= hi; s++) {
70
+ denom += denomArr[s]!;
71
+ const row = field.fields[base][s]!;
72
+ for (let g = 0; g < nCells; g++) out[g]! += row[g]!;
73
+ }
74
+ if (denom > 0) for (let g = 0; g < nCells; g++) out[g]! /= denom;
75
+ return out;
76
+ }
77
+
78
+ /** 单场在某模式下的逐格频率(未差分)。info-diff/contested 在此合成。 */
79
+ function modeFreq(field: RadarField, mode: RadarFieldMode, sec: number, window: number): Float64Array {
80
+ if (mode === "info-diff") {
81
+ const t = freqAt(field, "tVis", sec, window);
82
+ const c = freqAt(field, "ctVis", sec, window);
83
+ const out = new Float64Array(t.length);
84
+ for (let g = 0; g < t.length; g++) out[g]! = t[g]! - c[g]!;
85
+ return out;
86
+ }
87
+ if (mode === "contested") {
88
+ const t = freqAt(field, "tVis", sec, window);
89
+ const c = freqAt(field, "ctVis", sec, window);
90
+ const out = new Float64Array(t.length);
91
+ for (let g = 0; g < t.length; g++) out[g]! = Math.min(t[g]!, c[g]!);
92
+ return out;
93
+ }
94
+ return freqAt(field, mode, sec, window);
95
+ }
96
+
97
+ /**
98
+ * 合成某秒的渲染帧。baseline 非空 → 输出「队伍 − 联赛」偏移(signed)。
99
+ * @param smoothWindow 时间平滑半窗(±N 秒),默认 ±2s。
100
+ */
101
+ export function radarModeFrame(
102
+ field: RadarField,
103
+ baseline: RadarField | null,
104
+ mode: RadarFieldMode,
105
+ sec: number,
106
+ smoothWindow = 2
107
+ ): RadarModeFrame {
108
+ const team = modeFreq(field, mode, sec, smoothWindow);
109
+ if (!baseline) {
110
+ return { values: team, signed: mode === "info-diff", cap: MODE_CAP[mode] };
111
+ }
112
+ const base = modeFreq(baseline, mode, sec, smoothWindow);
113
+ const out = new Float64Array(team.length);
114
+ for (let g = 0; g < team.length; g++) out[g]! = team[g]! - base[g]!;
115
+ return { values: out, signed: true, cap: DELTA_CAP };
116
+ }
117
+
118
+ /** scope 内长枪局回合数(展示用);league=赛事总局,team=该队参与的局。 */
119
+ export function radarFieldRoundCount(field: RadarField): number {
120
+ return field.scope.roundCount;
121
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveReplayClock, formatClockSeconds } from "./replay-clock.js";
3
+
4
+ const round = {
5
+ freezeEndTick: 1_000,
6
+ officialEndTick: 10_000,
7
+ targetEndTick: 10_320,
8
+ bomb: null,
9
+ };
10
+
11
+ describe("replay clock", () => {
12
+ it("以 freeze end 为 1:55,并可定位到 1:35", () => {
13
+ expect(deriveReplayClock(round, 1_000, 64)).toMatchObject({ phase: "round", secondsRemaining: 115, display: "1:55" });
14
+ expect(deriveReplayClock(round, 1_000 + 20 * 64, 64)).toMatchObject({ phase: "round", secondsRemaining: 95, display: "1:35" });
15
+ });
16
+
17
+ it("下包后使用实际爆炸时长,缺失爆炸事件时回退 40 秒", () => {
18
+ const planted = { ...round, bomb: { plantTick: 5_000, explodeTick: 5_000 + 35 * 64, defuseTick: null } };
19
+ expect(deriveReplayClock(planted, 5_000, 64)).toMatchObject({ phase: "bomb", secondsRemaining: 35, display: "0:35" });
20
+ expect(deriveReplayClock({ ...planted, bomb: { ...planted.bomb, explodeTick: null } }, 5_000 + 10 * 64, 64))
21
+ .toMatchObject({ phase: "bomb", secondsRemaining: 30, display: "0:30" });
22
+ });
23
+
24
+ it("官方回合结束后按下一回合 start tick 显示真实赛后间隔", () => {
25
+ expect(deriveReplayClock(round, 10_000, 64)).toMatchObject({ phase: "round-end", secondsRemaining: 5, display: "0:05" });
26
+ expect(deriveReplayClock(round, 10_192, 64)).toMatchObject({ phase: "round-end", secondsRemaining: 2, display: "0:02" });
27
+ });
28
+
29
+ it("freeze 阶段显示准备,通用格式不输出裸秒", () => {
30
+ expect(deriveReplayClock(round, 999, 64)).toMatchObject({ phase: "freeze", secondsRemaining: null, display: "准备" });
31
+ expect(formatClockSeconds(88)).toBe("1:28");
32
+ });
33
+ });
@@ -0,0 +1,61 @@
1
+ export type ReplayClockPhase = "freeze" | "round" | "bomb" | "round-end";
2
+
3
+ export interface ReplayClockRound {
4
+ freezeEndTick: number;
5
+ officialEndTick?: number;
6
+ targetEndTick?: number;
7
+ bomb: {
8
+ plantTick: number;
9
+ defuseTick: number | null;
10
+ explodeTick: number | null;
11
+ } | null;
12
+ }
13
+
14
+ export interface ReplayClockState {
15
+ phase: ReplayClockPhase;
16
+ label: string;
17
+ secondsRemaining: number | null;
18
+ display: string;
19
+ }
20
+
21
+ const STANDARD_ROUND_SECONDS = 115;
22
+ const STANDARD_BOMB_SECONDS = 40;
23
+
24
+ export function formatClockSeconds(seconds: number): string {
25
+ const safe = Math.max(0, Math.ceil(seconds));
26
+ return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, "0")}`;
27
+ }
28
+
29
+ export function deriveReplayClock(
30
+ round: ReplayClockRound,
31
+ currentTick: number,
32
+ tickrate: number,
33
+ ): ReplayClockState {
34
+ const safeTickrate = tickrate > 0 ? tickrate : 64;
35
+ if (currentTick < round.freezeEndTick) {
36
+ return { phase: "freeze", label: "准备", secondsRemaining: null, display: "准备" };
37
+ }
38
+
39
+ if (round.officialEndTick != null && currentTick >= round.officialEndTick) {
40
+ const endTick = round.targetEndTick ?? round.officialEndTick;
41
+ const secondsRemaining = Math.max(0, (endTick - currentTick) / safeTickrate);
42
+ return {
43
+ phase: "round-end",
44
+ label: "下一回合",
45
+ secondsRemaining,
46
+ display: formatClockSeconds(secondsRemaining),
47
+ };
48
+ }
49
+
50
+ if (round.bomb && currentTick >= round.bomb.plantTick) {
51
+ const bombDuration = round.bomb.explodeTick != null
52
+ ? Math.max(0, (round.bomb.explodeTick - round.bomb.plantTick) / safeTickrate)
53
+ : STANDARD_BOMB_SECONDS;
54
+ const secondsRemaining = Math.max(0, bombDuration - (currentTick - round.bomb.plantTick) / safeTickrate);
55
+ return { phase: "bomb", label: "C4", secondsRemaining, display: formatClockSeconds(secondsRemaining) };
56
+ }
57
+
58
+ const secondsRemaining = Math.max(0, STANDARD_ROUND_SECONDS - (currentTick - round.freezeEndTick) / safeTickrate);
59
+ return { phase: "round", label: "回合", secondsRemaining, display: formatClockSeconds(secondsRemaining) };
60
+ }
61
+
@@ -15,6 +15,11 @@ export function round(value: number, digits = 2): number {
15
15
  return Math.round(value * factor) / factor;
16
16
  }
17
17
 
18
+ /** 百分比格式化:null → "—",否则 `${value.toFixed(digits)}%`。消除 `value == null ? "—" : `${value.toFixed(1)}%`` 的重复模式。 */
19
+ export function formatPercent(value: number | null, digits = 1): string {
20
+ return value == null ? "—" : `${value.toFixed(digits)}%`;
21
+ }
22
+
18
23
  /** 视图列定义(信息架构来自 RivalHub StatsLeaderboard,去掉 OCR 副指标与独立 Demo tab)。 */
19
24
  export const SEASON_STAT_VIEWS: LeaderboardView[] = [
20
25
  {