@cs2dak/react 1.0.0 → 1.1.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.
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { PlayerMapPoolRow, PlayerMapRoleProfile, TeamMapRoleMatrix } from "@cs2dak/contract";
5
+ import { PlayerMapPoolPanel, PlayerMapRoleProfilePanel, TeamMapRoleMatrixPanel } from "./MapRoles";
6
+
7
+ const profile = {
8
+ version: "cs2-demo-analysis-kit/player-map-role-profile-5.0", playerKey: "p1", teamKey: "T",
9
+ declaredRoles: [{ kind: "main_role", playerKey: "p1", role: "igl", priority: "primary", source: "user", provenance: "user" }], declaredWeaponDuties: [],
10
+ inferredPrimaryRole: "awper", runnerUpRole: "anchor", separationMargin: 0.4,
11
+ roleEvidenceScores: { awper: 0.9, anchor: 0.5, opener: 0.2, closer: 0.1 },
12
+ headlineRole: "IGL / AWPer", status: "ready", confidence: 0.8, weaponDuty: "primary_awper",
13
+ positionGroupDisplay: [{ mapName: "de_mirage", side: "ct", positionGroupId: "a_site", displayName: "A包", officialName: "BombsiteA", resolved: true }],
14
+ roleAlignments: [{ declaration: { kind: "main_role", playerKey: "p1", role: "igl", priority: "primary", source: "user", provenance: "user" }, declaredPrimary: "igl", declaredSecondary: [], inferredPrimary: "awper", overall: "not_comparable", tSide: "T 方观察职责:unknown", ctSide: "CT 方观察职责:anchor", disagreementReasons: [], sampleLimitations: [] }], weaponDutyAlignments: [],
15
+ perMapEvidence: [{
16
+ version: 4, playerKey: "p1", teamKey: "T", mapName: "de_mirage", side: "ct", status: "ready", confidence: 0.8,
17
+ sample: { observedRounds: 12, eligibleRounds: 12, eligibleSeconds: 240, matchCount: 3, dataQuality: 1, coverage: 1 },
18
+ matchIds: ["m1", "m2", "m3"],
19
+ positionGroups: [{ positionGroupId: "a_site", seconds: 200, share: 0.83, roundCount: 10 }],
20
+ spatial: { dominantGroupStability: 0.83, teamRelativeGroupShare: 0.2, isolationSeconds: 0, isolationShare: 0, rejoinCount: 0, delayedConvergenceRoundShare: 0, movementSync: 0.5, openingMainComponentShare: 0.4, openingNoUniqueCoreShare: 0, openingIsolatedShare: 0.4, formationShares: { "4+1": 1 } },
21
+ support: { utilityUses: 0, openingUtilityUses: 0, utilityUsePerRound: 0, openingUtilityUsePerRound: 0 },
22
+ responsibility: "anchor_tendency", modifiers: ["positionally_stable"],
23
+ awp: { duty: "primary_awper", eligibleRounds: 12, qualifiedLongGunRounds: 12, freezeOwnershipRounds: 8, activeSeconds: 100, shots: 12, kills: 5, teamActiveShare: 1, usageConcentration: 1, matchConsistency: 1 },
24
+ representativeRounds: [{ matchId: "m1", roundNumber: 2 }], basis: ["facts"], limitations: ["limit"],
25
+ }],
26
+ evidence: [{ matchId: "m1", roundNumber: 2, reason: "example", role: "example" }], basis: ["basis"], limitations: ["IGL 无法由 demo 统计验证。"],
27
+ } satisfies PlayerMapRoleProfile;
28
+
29
+ describe("Map role display", () => {
30
+ it("keeps declared and inferred role labels side-by-side and hides internal position ids", () => {
31
+ const html = renderToStaticMarkup(React.createElement(PlayerMapRoleProfilePanel, { profile, onOpenEvidence: () => {} }));
32
+ expect(html).toContain("自动画像"); expect(html).toContain("IGL / AWPer"); expect(html).toContain("A包"); expect(html).not.toContain("a_site"); expect(html).toContain("dak-evidence");
33
+ });
34
+ it("renders an honest insufficient matrix state", () => {
35
+ const html = renderToStaticMarkup(React.createElement(TeamMapRoleMatrixPanel, { matrix: null }));
36
+ expect(html).toContain("该地图暂无职责矩阵");
37
+ });
38
+ it("contains the wide player map pool table inside a horizontal scroll region", () => {
39
+ const row = {
40
+ mapName: "de_mirage", matchCount: 1, roundCount: 12, wins: 1, losses: 0, winRate: 1,
41
+ rr: 1.2, adr: 80, kast: 75, openingKills: 2, openingDeaths: 1, mainWeapon: "ak47",
42
+ globalWeaponDuty: "rifler", mapSideAwpUsage: [], tPositionGroup: "中路", ctPositionGroup: "A包",
43
+ tResponsibility: "stable_default", ctResponsibility: "anchor_tendency", sampleQuality: 1, confidence: 0.8,
44
+ evidence: [],
45
+ } satisfies PlayerMapPoolRow;
46
+ const html = renderToStaticMarkup(React.createElement(PlayerMapPoolPanel, { rows: [row] }));
47
+ expect(html).toContain('class="dak-table-scroll"');
48
+ });
49
+ void ({} as TeamMapRoleMatrix);
50
+ });
@@ -0,0 +1,122 @@
1
+ import type { DoubleAwpAnalysis, EvidenceRef, PlayerMapPoolRow, PlayerMapRoleProfile, TeamMapRoleMatrix } from "@cs2dak/contract";
2
+ import { DataTable, DAK_TABLE_CLASSES, type DataTableColumn } from "./DataTable";
3
+ import { EmptyState, EvidenceLink, LimitNote, MetricInfo } from "./Primitives";
4
+
5
+ const STATUS_LABEL = { ready: "可用", mixed: "混合", insufficient: "样本不足", unknown: "未知" } as const;
6
+ const DUTY_LABEL = { primary_awper: "主 AWP", secondary_awper: "副 AWP", situational_awper: "情境 AWP", rifler: "步枪" } as const;
7
+ const RESPONSIBILITY_LABEL = { pack: "主体组", extremity: "边翼", late_joining: "晚汇合", stable_default: "稳定默认位", anchor_tendency: "守点倾向", component_mobile: "组间移动", independent_mobile: "独立移动", stable_position: "稳定位置", mixed: "混合", unknown: "未知" } as const;
8
+ const MODIFIER_LABEL = { utility_supportive: "道具协同", positionally_stable: "位置稳定", spatially_isolated: "空间孤立", component_mobile: "组间移动" } as const;
9
+
10
+ function Status({ value }: { value: keyof typeof STATUS_LABEL }) {
11
+ return <span className={`dak-role-status dak-role-status-${value}`}>{STATUS_LABEL[value]}</span>;
12
+ }
13
+
14
+ function Evidence({ evidence, onOpenEvidence }: { evidence: EvidenceRef[]; onOpenEvidence?: (evidence: EvidenceRef) => void }) {
15
+ const first = evidence[0];
16
+ return first && onOpenEvidence ? <EvidenceLink onOpen={() => onOpenEvidence(first)}>R{first.roundNumber}</EvidenceLink> : "—";
17
+ }
18
+
19
+ export function PlayerMapRoleProfilePanel({
20
+ profile,
21
+ onOpenEvidence,
22
+ }: {
23
+ profile: PlayerMapRoleProfile | null;
24
+ onOpenEvidence?: (evidence: EvidenceRef) => void;
25
+ }) {
26
+ if (!profile) return <EmptyState variant="insufficient" title="地图 / 位置画像不可用" hint="需要当前选手的地图位置 facts;缺失时可在资料库重建 facts。" />;
27
+ const rows = profile.perMapEvidence;
28
+ const positionLabels = new Map(profile.positionGroupDisplay.map((row) => [`${row.mapName}:${row.side}:${row.positionGroupId}`, row]));
29
+ const columns: DataTableColumn<(typeof rows)[number]>[] = [
30
+ { key: "map", label: "地图 / 边", format: (row) => `${row.mapName.replace("de_", "")} · ${row.side.toUpperCase()}` },
31
+ { key: "sample", label: "样本", numeric: true, sortable: true, sortValue: (row) => row.sample.eligibleRounds, format: (row) => `${row.sample.eligibleRounds} 回合` },
32
+ { key: "position", label: "位置组", format: (row) => {
33
+ const group = row.positionGroups[0];
34
+ if (!group) return "—";
35
+ const display = positionLabels.get(`${row.mapName}:${row.side}:${group.positionGroupId}`);
36
+ return display ? `${display.displayName}${display.officialName ? ` / ${display.officialName}` : ""}` : "未映射位置";
37
+ } },
38
+ { key: "responsibility", label: "逐侧职责", format: (row) => RESPONSIBILITY_LABEL[row.responsibility] },
39
+ { key: "duty", label: "武器职责", format: (row) => DUTY_LABEL[row.awp.duty] },
40
+ { key: "confidence", label: <>置信度 <MetricInfo note="来自 position group、队内相对空间、AWP 责任和样本覆盖;不是固定槽位或战术真相。" /></>, numeric: true, sortable: true, sortValue: (row) => row.confidence, format: (row) => `${Math.round(row.confidence * 100)}%` },
41
+ { key: "evidence", label: "代表回合", render: (row) => <Evidence evidence={row.representativeRounds.map((ref) => ({ ...ref, reason: `${row.mapName} ${row.side.toUpperCase()} 的位置职责代表回合`, role: "example" as const }))} onOpenEvidence={onOpenEvidence} /> },
42
+ ];
43
+ return (
44
+ <section className="dak-role-panel">
45
+ <div className="dak-role-head"><div><h3>地图 / 位置</h3><p>自动推断与人工声明并列展示;不会互相覆盖。</p></div><Status value={profile.status} /></div>
46
+ <div className="dak-role-summary">
47
+ <span>队伍:<b>{profile.teamKey}</b></span>
48
+ <span>自动画像:<b>{profile.inferredPrimaryRole ?? "—"}</b></span>
49
+ <span>标题角色:<b>{profile.headlineRole ?? "—"}</b></span>
50
+ <span>武器职责:<b>{profile.weaponDuty ? DUTY_LABEL[profile.weaponDuty] : "—"}</b></span>
51
+ <span>次选:<b>{profile.runnerUpRole ?? "—"}</b></span>
52
+ <span>区分度:<b>{profile.separationMargin == null ? "—" : `${Math.round(profile.separationMargin * 100)}%`}</b></span>
53
+ </div>
54
+ <div className="dak-role-summary"><span>AWPer 倾向分 <b>{Math.round(profile.roleEvidenceScores.awper * 100)}</b></span><span>Anchor 倾向分 <b>{Math.round(profile.roleEvidenceScores.anchor * 100)}</b></span><span>Opener 倾向分 <b>{Math.round(profile.roleEvidenceScores.opener * 100)}</b></span><span>Closer 倾向分 <b>{Math.round(profile.roleEvidenceScores.closer * 100)}</b></span></div>
55
+ <div className="dak-role-declarations"><b>用户主角色</b>{profile.declaredRoles.filter((row) => row.source === "user").map((row, index) => <span key={`user-${row.role}-${index}`} className="dak-role-tag">{row.priority} · {row.role}{row.mapName ? ` · ${row.mapName.replace("de_", "")}` : ""}</span>)}{!profile.declaredRoles.some((row) => row.source === "user") && <span>未提供</span>}<b>用户武器职责</b>{profile.declaredWeaponDuties.filter((row) => row.source === "user").map((row, index) => <span key={`weapon-${row.weaponDuty}-${index}`} className="dak-role-tag">{DUTY_LABEL[row.weaponDuty]}{row.mapName ? ` · ${row.mapName.replace("de_", "")}` : ""}</span>)}{!profile.declaredWeaponDuties.some((row) => row.source === "user") && <span>未提供</span>}</div>
56
+ {rows.length ? <DataTable classes={DAK_TABLE_CLASSES} rows={rows} rowKey={(row) => `${row.teamKey}:${row.mapName}:${row.side}`} columns={columns} initialSortKey="sample" /> : <EmptyState variant="insufficient" title="没有可用地图样本" hint="当前范围没有满足角色证据条件的地图 / 阵营数据。" />}
57
+ {profile.roleAlignments.map((alignment, index) => <p className="dak-role-note" key={`role-alignment-${index}`}><b>声明与观察</b>{alignment.tSide};{alignment.ctSide}{alignment.sampleLimitations[0] ? `;${alignment.sampleLimitations[0]}` : ""}</p>)}
58
+ {profile.weaponDutyAlignments.map((alignment, index) => <p className="dak-role-note" key={`weapon-alignment-${index}`}><b>武器职责声明与观察</b>{DUTY_LABEL[alignment.declaration.weaponDuty]} / {alignment.observedWeaponDuty ? DUTY_LABEL[alignment.observedWeaponDuty] : "—"}</p>)}
59
+ {profile.basis[0] && <p className="dak-role-note"><b>依据</b>{profile.basis[0]}</p>}
60
+ {profile.limitations[0] && <LimitNote>{profile.limitations[0]}</LimitNote>}
61
+ </section>
62
+ );
63
+ }
64
+
65
+ export function TeamMapRoleMatrixPanel({
66
+ matrix,
67
+ playerNames = {},
68
+ onOpenEvidence,
69
+ }: {
70
+ matrix: TeamMapRoleMatrix | null;
71
+ playerNames?: Record<string, string>;
72
+ onOpenEvidence?: (evidence: EvidenceRef) => void;
73
+ }) {
74
+ if (!matrix) return <EmptyState variant="insufficient" title="该地图暂无职责矩阵" hint="选择有位置 facts 的地图和阵营;样本不足会明确保留,而不是补零。" />;
75
+ const columns: DataTableColumn<TeamMapRoleMatrix["players"][number]>[] = [
76
+ { key: "player", label: "选手", format: (row) => playerNames[row.playerKey] ?? row.playerKey },
77
+ { key: "position", label: "主要位置组", format: (row) => row.primaryPositionGroups.map((group) => `${group.displayName}${group.officialName ? ` / ${group.officialName}` : ""} ${Math.round(group.share * 100)}%`).join(" · ") || "—" },
78
+ { key: "responsibility", label: <>结构职责 <MetricInfo note="只描述当前空间证据支持的主体组、边翼、晚汇合或位置稳定倾向。" /></>, format: (row) => RESPONSIBILITY_LABEL[row.responsibility] },
79
+ { key: "modifiers", label: "附加属性", format: (row) => row.modifiers.map((modifier) => MODIFIER_LABEL[modifier]).join(" · ") || "—" },
80
+ { key: "awp", label: "AWP", format: (row) => row.weaponDuty ? DUTY_LABEL[row.weaponDuty] : "—" },
81
+ { key: "sample", label: "样本", numeric: true, sortable: true, sortValue: (row) => row.sampleRounds, format: (row) => `${row.sampleRounds} 回合` },
82
+ { key: "evidence", label: "证据", render: (row) => <Evidence evidence={row.evidence} onOpenEvidence={onOpenEvidence} /> },
83
+ ];
84
+ return (
85
+ <section className="dak-role-panel">
86
+ <div className="dak-role-head"><div><h3>{matrix.mapName.replace("de_", "")} · {matrix.side.toUpperCase()} 位置职责</h3><p>描述队伍地图盘面;不自动生成强弱判断或对策。</p></div><Status value={matrix.status} /></div>
87
+ <div className="dak-role-summary"><span>置信度:<b>{Math.round(matrix.confidence * 100)}%</b></span><span>覆盖不稳:<b>{matrix.unstableCoverage ? "是" : "否"}</b></span><span>位置集中度:<b>{matrix.positionConcentration == null ? "—" : `${Math.round(matrix.positionConcentration * 100)}%`}</b></span><span>位置重叠:<b>{matrix.positionOverlap.length}</b></span></div>
88
+ <DataTable classes={DAK_TABLE_CLASSES} rows={matrix.players} rowKey={(row) => row.playerKey} columns={columns} initialSortKey="sample" />
89
+ {matrix.representativeRounds.length > 0 && <div className="dak-role-evidence"><b>队伍代表回合</b><Evidence evidence={matrix.representativeRounds} onOpenEvidence={onOpenEvidence} /></div>}
90
+ {matrix.limitations[0] && <LimitNote>{matrix.limitations[0]}</LimitNote>}
91
+ </section>
92
+ );
93
+ }
94
+
95
+ export function DoubleAwpAnalysisPanel({ model, playerNames = {}, onOpenEvidence }: { model: DoubleAwpAnalysis | null; playerNames?: Record<string, string>; onOpenEvidence?: (evidence: EvidenceRef) => void }) {
96
+ if (!model) return <EmptyState variant="insufficient" title="双 AWP 样本不可用" hint="需要逐回合 AWP 与经济 facts;T/CT 会分别统计。" />;
97
+ return <section className="dak-role-panel">
98
+ <div className="dak-role-head"><div><h3>{model.side.toUpperCase()} 双 AWP</h3><p>仅描述 qualified full-buy / long-gun round 的观测指标,不作因果归因。</p></div><Status value={model.status} /></div>
99
+ <div className="dak-role-summary"><span>双 AWP 回合 <b>{model.doubleAwpRoundCount}/{model.qualifiedRoundCount}</b></span><span>占比 <b>{model.eligibleRoundShare == null ? "—" : `${Math.round(model.eligibleRoundShare * 100)}%`}</b></span><span>战绩 <b>{model.wins}/{model.doubleAwpRoundCount}</b></span><span>开局 <b>{model.openingKills}:{model.openingDeaths}</b></span><span>AWP kills <b>{model.awpKills ?? "—"}</b></span><span>AWP damage <b>{model.awpDamage ?? "—"}</b></span></div>
100
+ <div className="dak-role-declarations"><b>常见组合</b>{model.combinations.map((combo) => <span key={combo.playerKeys.join("|")} className="dak-role-tag">{combo.playerKeys.map((key) => playerNames[key] ?? key).join(" + ")} · {combo.rounds}</span>)}{model.combinations.length === 0 && <span>无</span>}</div>
101
+ {model.evidence.length > 0 && <div className="dak-role-evidence"><b>代表回合</b><Evidence evidence={model.evidence} onOpenEvidence={onOpenEvidence} /></div>}
102
+ {model.limitations.map((item) => <LimitNote key={item}>{item}</LimitNote>)}
103
+ </section>;
104
+ }
105
+
106
+ export function PlayerMapPoolPanel({ rows, onOpenEvidence }: { rows: PlayerMapPoolRow[]; onOpenEvidence?: (evidence: EvidenceRef) => void }) {
107
+ const columns: DataTableColumn<PlayerMapPoolRow>[] = [
108
+ { key: "map", label: "地图", format: (row) => row.mapName.replace("de_", "") },
109
+ { key: "record", label: "场次 / 战绩", sortable: true, sortValue: (row) => row.matchCount, format: (row) => `${row.matchCount} · ${row.wins}胜 ${row.losses}负` },
110
+ { key: "rounds", label: "回合", numeric: true, sortable: true, sortValue: (row) => row.roundCount, format: (row) => row.roundCount },
111
+ { key: "rr", label: "RR", numeric: true, sortable: true, sortValue: (row) => row.rr, format: (row) => row.rr?.toFixed(2) ?? "—" },
112
+ { key: "adr", label: "ADR", numeric: true, sortable: true, sortValue: (row) => row.adr, format: (row) => row.adr?.toFixed(1) ?? "—" },
113
+ { key: "kast", label: "KAST", numeric: true, sortable: true, sortValue: (row) => row.kast, format: (row) => row.kast == null ? "—" : `${row.kast.toFixed(1)}%` },
114
+ { key: "opening", label: "Opening", format: (row) => `${row.openingKills}:${row.openingDeaths}` },
115
+ { key: "weapon", label: "主武器 / 全局职责", format: (row) => `${row.mainWeapon ?? "—"} · ${row.globalWeaponDuty ? DUTY_LABEL[row.globalWeaponDuty] : "—"}` },
116
+ { key: "position", label: "T / CT 位置", format: (row) => `${row.tPositionGroup ?? "—"} / ${row.ctPositionGroup ?? "—"}` },
117
+ { key: "responsibility", label: "T / CT 职责", format: (row) => `${RESPONSIBILITY_LABEL[row.tResponsibility]} / ${RESPONSIBILITY_LABEL[row.ctResponsibility]}` },
118
+ { key: "quality", label: "样本 / 置信度", format: (row) => `${Math.round(row.sampleQuality * 100)}% / ${Math.round(row.confidence * 100)}%` },
119
+ { key: "evidence", label: "证据", render: (row) => <Evidence evidence={row.evidence} onOpenEvidence={onOpenEvidence} /> },
120
+ ];
121
+ return rows.length ? <div className="dak-table-scroll"><DataTable classes={DAK_TABLE_CLASSES} rows={rows} rowKey={(row) => row.mapName} columns={columns} initialSortKey="record" /></div> : <EmptyState variant="insufficient" title="地图池样本不足" hint="当前选手没有可聚合的地图级 facts。" />;
122
+ }
@@ -2,12 +2,12 @@ import React from "react";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
4
  import type { MatchWorkspaceModel } from "@cs2dak/contract";
5
- import { MatchWorkspace } from "./MatchWorkspace";
5
+ import { MatchWorkspace, ReplayViewer, replayInitialFrameIndex } from "./MatchWorkspace";
6
6
  import { RoundTimeline } from "./RoundTimeline";
7
7
 
8
8
  const model: MatchWorkspaceModel = {
9
9
  version: "cs2-demo-analysis-kit/workspace-0.1",
10
- sourceSchemaVersion: "cs2-demo-format/2.0",
10
+ sourceSchemaVersion: "cs2-demo-format/3.0",
11
11
  title: "Team A vs Team B",
12
12
  subtitle: "de_ancient · 10 名选手 · 21 回合",
13
13
  scoreline: "13:8",
@@ -21,6 +21,8 @@ const model: MatchWorkspaceModel = {
21
21
  { key: "rounds", label: "回合" },
22
22
  { key: "players", label: "选手" },
23
23
  { key: "economy", label: "经济" },
24
+ { key: "weapons", label: "武器" },
25
+ { key: "duels", label: "对位" },
24
26
  { key: "map", label: "地图" },
25
27
  { key: "replay", label: "回放" }
26
28
  ],
@@ -34,8 +36,36 @@ const model: MatchWorkspaceModel = {
34
36
  rounds: [],
35
37
  players: [],
36
38
  economy: [],
39
+ weapons: [
40
+ {
41
+ weapon: "ak47",
42
+ label: "AK-47",
43
+ kills: 12,
44
+ headshotPercent: 41.7,
45
+ damage: 1804,
46
+ wallbangKills: 1,
47
+ noScopeKills: 0,
48
+ throughSmokeKills: 2,
49
+ topKillerName: "Player A",
50
+ topKillerKills: 7
51
+ }
52
+ ],
53
+ duels: {
54
+ players: [
55
+ { steamId64: "76561198000000001", name: "Player A", teamKey: "teamA" },
56
+ { steamId64: "76561198000000002", name: "Player B", teamKey: "teamB" }
57
+ ],
58
+ matrix: [
59
+ [0, 3],
60
+ [1, 0]
61
+ ],
62
+ openings: [
63
+ { steamId64: "76561198000000001", name: "Player A", teamKey: "teamA", openingKills: 4, openingDeaths: 2, winRatePercent: 66.7 },
64
+ { steamId64: "76561198000000002", name: "Player B", teamKey: "teamB", openingKills: 2, openingDeaths: 4, winRatePercent: 33.3 }
65
+ ]
66
+ },
37
67
  map: {
38
- view: { name: "de_ancient", radarImageUrl: "/maps/radars/de_ancient.png", calibrated: true },
68
+ view: { name: "de_ancient", radarImageUrl: "/maps/radars/de_ancient.png", lowerRadarImageUrl: null, calibrated: true },
39
69
  modes: [],
40
70
  points: [],
41
71
  status: { hasRadar: true, hasPositionData: false, message: "该导出包暂无可展示的位置数据" }
@@ -48,23 +78,48 @@ const model: MatchWorkspaceModel = {
48
78
  {
49
79
  roundNumber: 1,
50
80
  startTick: 100,
81
+ freezeEndTick: 100,
51
82
  tickStep: 8,
52
83
  frameCount: 1,
53
84
  kills: [],
85
+ grenades: [
86
+ {
87
+ grenade: "smoke",
88
+ throwerSide: "ct",
89
+ throwTick: 90,
90
+ effectTick: 100,
91
+ destroyTick: 300,
92
+ throwX: 1,
93
+ throwY: 2,
94
+ effectX: 1,
95
+ effectY: 2,
96
+ effectZ: 3
97
+ }
98
+ ],
99
+ projectiles: [],
100
+ groundBombs: [],
101
+ groundDefusers: [],
102
+ bomb: null,
54
103
  players: [
55
104
  {
56
105
  steamId64: "76561198000000001",
57
106
  name: "Player A",
58
107
  teamKey: "teamA",
59
108
  side: "ct",
109
+ loadout: {
110
+ primaryWeapon: "ak47",
111
+ secondaryWeapon: "usp_silencer",
112
+ grenadeCount: 2,
113
+ grenades: ["smoke", "flashbang"]
114
+ },
60
115
  frames: [
61
- { tick: 100, x: 1, y: 2, z: 3, yaw: 90, hp: 100, weapon: "ak47", alive: true, flashed: false, hasDefuseKit: true }
116
+ { tick: 100, x: 1, y: 2, z: 3, yaw: 90, hp: 100, armor: 100, weapon: "ak47", grenades: ["smoke"], alive: true, flashed: false, flashRemainingSeconds: 0, hasDefuseKit: true, hasBomb: false, hasHelmet: true }
62
117
  ]
63
118
  }
64
119
  ]
65
120
  }
66
121
  ],
67
- capabilities: { hasDefuseKit: true, hasBombPosition: false }
122
+ capabilities: { hasDefuseKit: true }
68
123
  },
69
124
  adminQa: {
70
125
  ok: true,
@@ -81,13 +136,54 @@ describe("MatchWorkspace", () => {
81
136
  expect(html).toContain("最高 V2 RR");
82
137
  expect(html).toContain("回放");
83
138
  expect(html).toContain("8 Hz");
84
- expect(html).toContain("拆弹器");
139
+ expect(html).toContain("走位 / 道具 / C4 时间线");
85
140
  expect(html).not.toContain("QA</span>");
86
141
  expect(html).not.toContain("pr1maly");
87
142
  expect(html).not.toContain("AWPy");
88
143
  expect(html).not.toContain("CS Demo Manager");
89
144
  });
90
145
 
146
+ it("renders stable loadout and current held utility in replay roster", () => {
147
+ const html = renderToStaticMarkup(React.createElement(ReplayViewer, {
148
+ replay: model.replay,
149
+ map: model.map.view
150
+ }));
151
+
152
+ expect(html).toContain("AK-47");
153
+ expect(html).toContain("USP-S");
154
+ expect(html).toContain("烟");
155
+ expect(html).not.toContain("闪");
156
+ });
157
+
158
+ it("colors replay smoke by current side instead of team identity", () => {
159
+ const html = renderToStaticMarkup(React.createElement(ReplayViewer, {
160
+ replay: model.replay,
161
+ map: model.map.view
162
+ }));
163
+
164
+ expect(html).toContain("dak-replay-fx-ct");
165
+ expect(html).not.toContain("dak-replay-fx-teamA");
166
+ });
167
+
168
+ it("starts coach replay at 1:35 and renders the shared round clock", () => {
169
+ const round = {
170
+ ...model.replay.rounds[0]!,
171
+ frameCount: 201,
172
+ targetEndTick: 1700
173
+ };
174
+ const replay = { ...model.replay, rounds: [round] };
175
+
176
+ expect(replayInitialFrameIndex(round, 64, 95)).toBe(160);
177
+
178
+ const html = renderToStaticMarkup(React.createElement(ReplayViewer, {
179
+ replay,
180
+ map: model.map.view,
181
+ initialClockSeconds: 95
182
+ }));
183
+ expect(html).toContain("回合时间");
184
+ expect(html).toContain("1:35");
185
+ });
186
+
91
187
  it("renders truncated timelines with an explicit expand control", () => {
92
188
  const events = Array.from({ length: 3 }, (_, index) => ({
93
189
  id: `event-${index}`,