@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.
@@ -0,0 +1,522 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import { buildPlayerRoundUtilityFacts, loadDemoPackageFromZip } from "@cs2dak/core";
5
+ import {
6
+ buildMatchBuyQuality,
7
+ buildMatchReportMarkdown,
8
+ buildPlayerFlashSummaries,
9
+ buildPlayerSeasonInsights,
10
+ buildUtilityValueSummary,
11
+ mergeUtilityValueSummaries,
12
+ } from "./insights";
13
+ import { buildTournamentInsights, buildTournamentInsightsFromFacts, extractTournamentFacts, type TournamentFacts } from "./tournament-compat";
14
+ import { buildMatchWorkspaceModel } from "./workspace";
15
+
16
+ const fixture = (async () => loadDemoPackageFromZip(await readFile(
17
+ fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
18
+ )))();
19
+ const workspaceFixture = fixture.then(buildMatchWorkspaceModel);
20
+
21
+ describe("buildPlayerSeasonInsights", () => {
22
+ it("derives trend, flash value and mistakes from one match", async () => {
23
+ const pkg = await fixture;
24
+ const steamId64 = pkg.players[pkg.playerStats[0].playerIndex]?.steamId64 ?? "";
25
+ const insights = buildPlayerSeasonInsights([{ matchId: "m1", pkg }], [steamId64]);
26
+
27
+ expect(insights.trend).toHaveLength(1);
28
+ const point = insights.trend[0];
29
+ expect(point.matchId).toBe("m1");
30
+ expect(point.mapName).toBe("de_ancient");
31
+ expect(point.adr).toBeGreaterThan(0);
32
+ expect(point.kast).toBeGreaterThan(0);
33
+ expect(point.kast).toBeLessThanOrEqual(100);
34
+
35
+ // 死亡分布总数 = 该选手 deaths(kills.json 口径)
36
+ const deaths = pkg.kills.filter((k) => pkg.players[k.victimIndex]?.steamId64 === steamId64).length;
37
+ const dt = insights.mistakes.deathTiming;
38
+ expect(dt.early + dt.mid + dt.late).toBe(dt.total);
39
+ expect(dt.total).toBe(deaths);
40
+
41
+ // flash value 字段自洽
42
+ expect(insights.flash.enemyBlindSeconds).toBeGreaterThanOrEqual(0);
43
+ expect(insights.flash.enemyBlindVictims).toBeGreaterThanOrEqual(0);
44
+ if (insights.flash.flashesThrown === 0) {
45
+ expect(insights.flash.netSecondsPerFlash).toBeNull();
46
+ expect(insights.flash.enemySecondsPerFlash).toBeNull();
47
+ } else {
48
+ expect(insights.flash.enemySecondsPerFlash).toBeCloseTo(
49
+ insights.flash.enemyBlindSeconds / insights.flash.flashesThrown,
50
+ 1
51
+ );
52
+ }
53
+
54
+ // 首死统计:count ≤ attempts,三个口径各自自洽
55
+ for (const stat of [
56
+ insights.mistakes.lowBuyFirstDeaths,
57
+ insights.mistakes.fullBuyFirstDeaths,
58
+ insights.mistakes.antiEcoFirstDeaths
59
+ ]) {
60
+ expect(stat.count).toBeLessThanOrEqual(stat.attempts);
61
+ expect(stat.evidence.length).toBeLessThanOrEqual(stat.count);
62
+ }
63
+ });
64
+
65
+ it("returns empty insights for unknown player", async () => {
66
+ const pkg = await fixture;
67
+ const insights = buildPlayerSeasonInsights([{ matchId: "m1", pkg }], ["76561190000000000"]);
68
+ expect(insights.trend).toHaveLength(0);
69
+ expect(insights.mistakes.deathTiming.total).toBe(0);
70
+ });
71
+ });
72
+
73
+ describe("buildPlayerFlashSummaries", () => {
74
+ it("matches the existing per-player flash value derivation", async () => {
75
+ const pkg = await fixture;
76
+ const players = pkg.players.slice(0, 4).map((player) => ({
77
+ playerKey: `steam:${player.steamId64}`,
78
+ name: player.name,
79
+ steamIds: [player.steamId64]
80
+ }));
81
+ const demos = [{ matchId: "m1", pkg }];
82
+ const summaries = buildPlayerFlashSummaries(demos, players);
83
+
84
+ for (const player of players) {
85
+ const playerIndex = pkg.players.findIndex((candidate) => candidate.steamId64 === player.steamIds[0]);
86
+ const stats = pkg.playerStats.find((row) => row.playerIndex === playerIndex)!;
87
+ const expected = buildPlayerSeasonInsights(demos, player.steamIds).flash;
88
+ const actual = summaries.find((row) => row.playerKey === player.playerKey);
89
+ expect(actual).toBeDefined();
90
+ expect(actual?.flashesThrown).toBe(expected.flashesThrown);
91
+ expect(actual?.enemyBlindSeconds).toBe(expected.enemyBlindSeconds);
92
+ expect(actual?.teamBlindSeconds).toBe(expected.teamBlindSeconds);
93
+ expect(actual?.enemyBlindVictims).toBe(expected.enemyBlindVictims);
94
+ expect(actual?.enemySecondsPerFlash).toBe(expected.enemySecondsPerFlash);
95
+ expect(actual?.netSecondsPerFlash).toBe(expected.netSecondsPerFlash);
96
+ expect(actual?.flashAssists).toBe(expected.flashAssists);
97
+ expect(actual?.worstTeamFlashes).toEqual(expected.worstTeamFlashes);
98
+ expect(actual?.enemyBlindSeconds).toBe(Math.round(stats.enemyFlashDurationSeconds * 10) / 10);
99
+ expect(actual?.teamBlindSeconds).toBe(Math.round(stats.teamFlashDurationSeconds * 10) / 10);
100
+ expect(actual?.flashAssists).toBe(stats.flashAssistCount);
101
+ expect(actual?.enemyBlindVictims).toBe(pkg.blinds.filter((blind) => blind.flasherIndex === playerIndex
102
+ && pkg.players[blind.flashedIndex]?.teamKey !== pkg.players[playerIndex]?.teamKey).length);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe("buildUtilityValueSummary", () => {
108
+ it("uses the shared core utility facts without changing Studio totals", async () => {
109
+ const pkg = await fixture;
110
+ const players = pkg.players.slice(0, 4).map((player) => ({
111
+ playerKey: `steam:${player.steamId64}`,
112
+ name: player.name,
113
+ steamIds: [player.steamId64]
114
+ }));
115
+ const summary = buildUtilityValueSummary([{ matchId: "m1", pkg }], players);
116
+ const facts = buildPlayerRoundUtilityFacts(pkg);
117
+
118
+ for (const player of players) {
119
+ const playerIndex = pkg.players.findIndex((candidate) => candidate.steamId64 === player.steamIds[0]);
120
+ const stats = pkg.playerStats.find((row) => row.playerIndex === playerIndex)!;
121
+ const expected = facts.filter((fact) => fact.steamId64 === player.steamIds[0]).reduce((total, fact) => ({
122
+ flashesThrown: total.flashesThrown + fact.flashesThrown,
123
+ enemyBlindSeconds: total.enemyBlindSeconds + fact.enemyBlindSeconds,
124
+ flashAssists: total.flashAssists + fact.flashAssists,
125
+ heThrows: total.heThrows + fact.heThrows,
126
+ heDamage: total.heDamage + fact.heDamage,
127
+ fireThrows: total.fireThrows + fact.fireThrows,
128
+ fireDamage: total.fireDamage + fact.fireDamage,
129
+ smokesThrown: total.smokesThrown + fact.smokesThrown,
130
+ }), { flashesThrown: 0, enemyBlindSeconds: 0, flashAssists: 0, heThrows: 0, heDamage: 0, fireThrows: 0, fireDamage: 0, smokesThrown: 0 });
131
+ const actual = summary.players.find((row) => row.id === player.playerKey);
132
+ expect(actual).toMatchObject({ ...expected, enemyBlindSeconds: Math.round(expected.enemyBlindSeconds * 10) / 10 });
133
+ expect(actual?.enemyBlindSeconds).toBe(Math.round(stats.enemyFlashDurationSeconds * 10) / 10);
134
+ expect(actual?.flashAssists).toBe(stats.flashAssistCount);
135
+ }
136
+ });
137
+
138
+ it("normalizes flash, HE, fire and smoke value by rounds or throws", async () => {
139
+ const pkg = await fixture;
140
+ const players = pkg.players.slice(0, 4).map((player) => ({
141
+ playerKey: `steam:${player.steamId64}`,
142
+ name: player.name,
143
+ steamIds: [player.steamId64]
144
+ }));
145
+ const summary = buildUtilityValueSummary([{ matchId: "m1", pkg }], players);
146
+
147
+ expect(summary.players).toHaveLength(players.length);
148
+ expect(summary.teams).toHaveLength(2);
149
+
150
+ for (const row of [...summary.players, ...summary.teams]) {
151
+ if (row.rounds > 0) {
152
+ expect(row.enemyBlindSecondsPerRound).toBeCloseTo(row.enemyBlindSeconds > 0 ? row.enemyBlindSeconds / row.rounds : 0, 1);
153
+ expect(row.smokesPerRound).toBe(Math.round((row.smokesThrown / row.rounds) * 1000) / 1000);
154
+ expect(row.heDamagePerRound).toBe(Math.round((row.heDamage / row.rounds) * 100) / 100);
155
+ expect(row.fireDamagePerRound).toBe(Math.round((row.fireDamage / row.rounds) * 100) / 100);
156
+ }
157
+ if (row.flashesThrown > 0) expect(row.enemyBlindSecondsPerFlash).toBeCloseTo(row.enemyBlindSeconds / row.flashesThrown, 1);
158
+ else expect(row.enemyBlindSecondsPerFlash).toBeNull();
159
+ expect(row.heDamagePerThrow).toBe(row.heThrows > 0 ? Math.round((row.heDamage / row.heThrows) * 100) / 100 : null);
160
+ expect(row.fireDamagePerThrow).toBe(row.fireThrows > 0 ? Math.round((row.fireDamage / row.fireThrows) * 100) / 100 : null);
161
+ }
162
+
163
+ for (const evidence of summary.bestDamageRounds) {
164
+ expect(evidence.damage).toBeGreaterThan(0);
165
+ expect(evidence.victimCount).toBeGreaterThan(0);
166
+ }
167
+ });
168
+
169
+ it("merges persisted per-match utility summaries by identity", async () => {
170
+ const pkg = await fixture;
171
+ const players = pkg.players.slice(0, 2).map((player) => ({
172
+ playerKey: `steam:${player.steamId64}`,
173
+ name: player.name,
174
+ steamIds: [player.steamId64]
175
+ }));
176
+ const mergedPlayer = {
177
+ playerKey: "merged",
178
+ name: "Merged Player",
179
+ steamIds: players.flatMap((player) => player.steamIds)
180
+ };
181
+ const summary = buildUtilityValueSummary([{ matchId: "m1", pkg }], players);
182
+ const merged = mergeUtilityValueSummaries([summary], { players: [mergedPlayer] });
183
+ const expectedRounds = summary.players.reduce((total, row) => total + row.rounds, 0);
184
+ const expectedFlashAssists = summary.players.reduce((total, row) => total + row.flashAssists, 0);
185
+
186
+ expect(merged.players).toHaveLength(1);
187
+ expect(merged.players[0]?.id).toBe("merged");
188
+ expect(merged.players[0]?.rounds).toBe(expectedRounds);
189
+ expect(merged.players[0]?.flashAssists).toBe(expectedFlashAssists);
190
+ });
191
+ });
192
+
193
+ describe("buildMatchBuyQuality", () => {
194
+ it("win counts never exceed round counts and pistol rounds exist", async () => {
195
+ const model = await workspaceFixture;
196
+ const quality = buildMatchBuyQuality(model.economy);
197
+
198
+ for (const row of [...quality.teamA, ...quality.teamB]) {
199
+ expect(row.wins).toBeLessThanOrEqual(row.rounds);
200
+ expect(row.winRatePercent).not.toBeNull();
201
+ }
202
+ expect(quality.teamA.some((row) => row.economy === "pistol")).toBe(true);
203
+ expect(quality.conversion.teamA.wins).toBeLessThanOrEqual(quality.conversion.teamA.rounds);
204
+ });
205
+ });
206
+
207
+ describe("buildTournamentInsights", () => {
208
+ it("builds the same model from persisted tournament facts", async () => {
209
+ const pkg = await fixture;
210
+ const demos = [
211
+ { matchId: "m1", pkg },
212
+ { matchId: "m2", pkg }
213
+ ];
214
+
215
+ expect(buildTournamentInsightsFromFacts(demos.map(extractTournamentFacts))).toEqual(buildTournamentInsights(demos));
216
+ });
217
+
218
+ it("preserves the Ancient fixture counts through the shared tournament owner", async () => {
219
+ const pkg = await fixture;
220
+ const facts = extractTournamentFacts({ matchId: "m1", pkg });
221
+ const fromDemo = buildTournamentInsights([{ matchId: "m1", pkg }]);
222
+ const fromFacts = buildTournamentInsightsFromFacts([facts]);
223
+ const selectStableFields = (insights: ReturnType<typeof buildTournamentInsights>) => ({
224
+ maps: insights.maps,
225
+ teamPistols: insights.teamPistols,
226
+ economyMatrix: insights.economyMatrix,
227
+ ecoUpsets: insights.ecoUpsets,
228
+ manAdvantageConversions: insights.manAdvantageConversions,
229
+ teamManAdvantageConversions: insights.teamManAdvantageConversions,
230
+ teamEconomySummaries: insights.teamEconomySummaries,
231
+ weaponKills: insights.weaponKills,
232
+ tWinRatePercent: insights.tWinRatePercent,
233
+ ctWinRatePercent: insights.ctWinRatePercent,
234
+ pistolConversionPercent: insights.pistolConversionPercent,
235
+ });
236
+ const expected = {
237
+ maps: [{ mapName: "de_ancient", matches: 1, tWinRatePercent: 69.6, ctWinRatePercent: 30.4, pistolTWinRatePercent: 50 }],
238
+ teamPistols: [
239
+ { teamName: "Team Spirit", pistolRounds: 2, pistolWins: 2, winRatePercent: 100, conversionRounds: 2, conversionWins: 1, conversionPercent: 50, breakRounds: 0, breakWins: 0, breakRatePercent: null },
240
+ { teamName: "Team Falcons", pistolRounds: 2, pistolWins: 0, winRatePercent: 0, conversionRounds: 0, conversionWins: 0, conversionPercent: null, breakRounds: 2, breakWins: 1, breakRatePercent: 50 },
241
+ ],
242
+ economyMatrix: [
243
+ { lowEconomy: "full", highEconomy: "full", rounds: 10, lowEconomyWins: 6, lowWinRatePercent: null },
244
+ { lowEconomy: "force", highEconomy: "full", rounds: 4, lowEconomyWins: 4, lowWinRatePercent: 100 },
245
+ { lowEconomy: "eco", highEconomy: "full", rounds: 3, lowEconomyWins: 0, lowWinRatePercent: 0 },
246
+ { lowEconomy: "semi", highEconomy: "full", rounds: 3, lowEconomyWins: 1, lowWinRatePercent: 33.3 },
247
+ { lowEconomy: "force", highEconomy: "force", rounds: 1, lowEconomyWins: 0, lowWinRatePercent: null },
248
+ ],
249
+ ecoUpsets: [
250
+ { teamName: "Team Spirit", opportunities: 3, wins: 1, winRatePercent: 33.3 },
251
+ { teamName: "Team Falcons", opportunities: 3, wins: 0, winRatePercent: 0 },
252
+ ],
253
+ manAdvantageConversions: [
254
+ { advantageAlive: 5, disadvantageAlive: 4, advantageLabel: "5v4", disadvantageLabel: "4v5", opportunities: 23, advantageWins: 17, advantageConversionPercent: 73.9, disadvantageWins: 6, disadvantageConversionPercent: 26.1 },
255
+ { advantageAlive: 5, disadvantageAlive: 3, advantageLabel: "5v3", disadvantageLabel: "3v5", opportunities: 9, advantageWins: 7, advantageConversionPercent: 77.8, disadvantageWins: 2, disadvantageConversionPercent: 22.2 },
256
+ ],
257
+ teamManAdvantageConversions: [
258
+ {
259
+ teamName: "Team Falcons",
260
+ states: [
261
+ { advantageAlive: 5, disadvantageAlive: 4, advantageLabel: "5v4", disadvantageLabel: "4v5", advantageOpportunities: 6, advantageWins: 5, advantageConversionPercent: 83.3, disadvantageOpportunities: 17, disadvantageWins: 5, disadvantageConversionPercent: 29.4 },
262
+ { advantageAlive: 5, disadvantageAlive: 3, advantageLabel: "5v3", disadvantageLabel: "3v5", advantageOpportunities: 5, advantageWins: 4, advantageConversionPercent: 80, disadvantageOpportunities: 4, disadvantageWins: 1, disadvantageConversionPercent: 25 },
263
+ ],
264
+ },
265
+ {
266
+ teamName: "Team Spirit",
267
+ states: [
268
+ { advantageAlive: 5, disadvantageAlive: 4, advantageLabel: "5v4", disadvantageLabel: "4v5", advantageOpportunities: 17, advantageWins: 12, advantageConversionPercent: 70.6, disadvantageOpportunities: 6, disadvantageWins: 1, disadvantageConversionPercent: 16.7 },
269
+ { advantageAlive: 5, disadvantageAlive: 3, advantageLabel: "5v3", disadvantageLabel: "3v5", advantageOpportunities: 4, advantageWins: 3, advantageConversionPercent: 75, disadvantageOpportunities: 5, disadvantageWins: 1, disadvantageConversionPercent: 20 },
270
+ ],
271
+ },
272
+ ],
273
+ teamEconomySummaries: [
274
+ {
275
+ teamName: "Team Spirit", maps: 1, rounds: 23, roundWins: 13, roundWinPercent: 56.5,
276
+ pistol: { rounds: 2, wins: 2, winRatePercent: 100 },
277
+ round2: { conversionRounds: 2, conversionWins: 1, conversionPercent: 50, breakRounds: 0, breakWins: 0, breakRatePercent: null },
278
+ manAdvantage: {
279
+ teamName: "Team Spirit",
280
+ states: [
281
+ { advantageAlive: 5, disadvantageAlive: 4, advantageLabel: "5v4", disadvantageLabel: "4v5", advantageOpportunities: 17, advantageWins: 12, advantageConversionPercent: 70.6, disadvantageOpportunities: 6, disadvantageWins: 1, disadvantageConversionPercent: 16.7 },
282
+ { advantageAlive: 5, disadvantageAlive: 3, advantageLabel: "5v3", disadvantageLabel: "3v5", advantageOpportunities: 4, advantageWins: 3, advantageConversionPercent: 75, disadvantageOpportunities: 5, disadvantageWins: 1, disadvantageConversionPercent: 20 },
283
+ ],
284
+ },
285
+ smallBuyUpset: { opportunities: 3, wins: 1, winRatePercent: 33.3 },
286
+ },
287
+ {
288
+ teamName: "Team Falcons", maps: 1, rounds: 23, roundWins: 10, roundWinPercent: 43.5,
289
+ pistol: { rounds: 2, wins: 0, winRatePercent: 0 },
290
+ round2: { conversionRounds: 0, conversionWins: 0, conversionPercent: null, breakRounds: 2, breakWins: 1, breakRatePercent: 50 },
291
+ manAdvantage: {
292
+ teamName: "Team Falcons",
293
+ states: [
294
+ { advantageAlive: 5, disadvantageAlive: 4, advantageLabel: "5v4", disadvantageLabel: "4v5", advantageOpportunities: 6, advantageWins: 5, advantageConversionPercent: 83.3, disadvantageOpportunities: 17, disadvantageWins: 5, disadvantageConversionPercent: 29.4 },
295
+ { advantageAlive: 5, disadvantageAlive: 3, advantageLabel: "5v3", disadvantageLabel: "3v5", advantageOpportunities: 5, advantageWins: 4, advantageConversionPercent: 80, disadvantageOpportunities: 4, disadvantageWins: 1, disadvantageConversionPercent: 25 },
296
+ ],
297
+ },
298
+ smallBuyUpset: { opportunities: 3, wins: 0, winRatePercent: 0 },
299
+ },
300
+ ],
301
+ weaponKills: [
302
+ { weapon: "ak47", label: "AK-47", kills: 60, headshotPercent: 56.7, topPlayerName: "donk", topPlayerKills: 19 },
303
+ { weapon: "m4a1", label: "M4A4", kills: 19, headshotPercent: 21.1, topPlayerName: "kyousuke", topPlayerKills: 6 },
304
+ { weapon: "m4a1_silencer", label: "M4A1-S", kills: 19, headshotPercent: 26.3, topPlayerName: "zont1x", topPlayerKills: 8 },
305
+ { weapon: "awp", label: "AWP", kills: 14, headshotPercent: 0, topPlayerName: "m0NESY", topPlayerKills: 9 },
306
+ { weapon: "usp_silencer", label: "USP-S", kills: 9, headshotPercent: 66.7, topPlayerName: "donk", topPlayerKills: 3 },
307
+ { weapon: "galilar", label: "Galil AR", kills: 7, headshotPercent: 42.9, topPlayerName: "NiKo", topPlayerKills: 2 },
308
+ { weapon: "glock", label: "Glock-18", kills: 7, headshotPercent: 100, topPlayerName: "kyousuke", topPlayerKills: 2 },
309
+ { weapon: "tec9", label: "Tec-9", kills: 4, headshotPercent: 75, topPlayerName: "karrigan", topPlayerKills: 2 },
310
+ { weapon: "deagle", label: "Desert Eagle", kills: 3, headshotPercent: 33.3, topPlayerName: "sh1ro", topPlayerKills: 2 },
311
+ { weapon: "mp9", label: "MP9", kills: 3, headshotPercent: 33.3, topPlayerName: "magixx", topPlayerKills: 3 },
312
+ ],
313
+ tWinRatePercent: 69.6,
314
+ ctWinRatePercent: 30.4,
315
+ pistolConversionPercent: 50,
316
+ };
317
+
318
+ expect(selectStableFields(fromDemo)).toEqual(expected);
319
+ expect(selectStableFields(fromFacts)).toEqual(expected);
320
+ });
321
+
322
+ it("aggregates round-level rates across demos", async () => {
323
+ const pkg = await fixture;
324
+ const insights = buildTournamentInsights([
325
+ { matchId: "m1", pkg },
326
+ { matchId: "m2", pkg }
327
+ ]);
328
+ expect(insights.matchCount).toBe(2);
329
+ expect(insights.roundCount).toBe(pkg.rounds.length * 2);
330
+ expect(insights.tWinRatePercent + insights.ctWinRatePercent).toBeCloseTo(100, 0);
331
+ expect(insights.maps[0].mapName).toBe("de_ancient");
332
+ expect(insights.maps[0].matches).toBe(2);
333
+
334
+ // 经济矩阵:按高低经济重排,手枪局不入矩阵;同档对局不出胜率
335
+ for (const cell of insights.economyMatrix) {
336
+ expect(cell.lowEconomy).not.toBe("pistol");
337
+ expect(cell.highEconomy).not.toBe("pistol");
338
+ if (cell.lowEconomy === cell.highEconomy) expect(cell.lowWinRatePercent).toBeNull();
339
+ else expect(cell.lowWinRatePercent).not.toBeNull();
340
+ expect(cell.lowEconomyWins).toBeLessThanOrEqual(cell.rounds);
341
+ if (cell.lowWinRatePercent != null) {
342
+ expect(cell.lowWinRatePercent).toBe(Math.round((cell.lowEconomyWins / cell.rounds) * 1000) / 10);
343
+ }
344
+ }
345
+
346
+ // 反转换:机会数 ≥ 成功数,全队 breakRounds 总和 = 全队 conversionRounds 总和
347
+ const totalBreakRounds = insights.teamPistols.reduce((acc, row) => acc + row.breakRounds, 0);
348
+ const totalConversionRounds = insights.teamPistols.reduce((acc, row) => acc + row.conversionRounds, 0);
349
+ expect(totalBreakRounds).toBe(totalConversionRounds);
350
+ for (const row of insights.teamPistols) {
351
+ expect(row.breakWins).toBeLessThanOrEqual(row.breakRounds);
352
+ }
353
+ });
354
+
355
+ it("tracks first 5v4 and 5v3 round-state conversion opportunities", async () => {
356
+ const pkg = await fixture;
357
+ const insights = buildTournamentInsights([{ matchId: "m1", pkg }]);
358
+ const expected = expectedManAdvantageRows(pkg);
359
+
360
+ for (const row of expected) {
361
+ const actual = insights.manAdvantageConversions.find(
362
+ (candidate) => candidate.advantageAlive === row.advantageAlive && candidate.disadvantageAlive === row.disadvantageAlive
363
+ );
364
+ expect(actual).toBeDefined();
365
+ expect(actual?.opportunities).toBe(row.opportunities);
366
+ expect(actual?.advantageWins).toBe(row.advantageWins);
367
+ expect(actual?.disadvantageWins).toBe(row.disadvantageWins);
368
+ expect(actual?.advantageConversionPercent).toBe(
369
+ row.opportunities > 0 ? Math.round((row.advantageWins / row.opportunities) * 1000) / 10 : null
370
+ );
371
+ expect(actual?.disadvantageConversionPercent).toBe(
372
+ row.opportunities > 0 ? Math.round((row.disadvantageWins / row.opportunities) * 1000) / 10 : null
373
+ );
374
+ }
375
+
376
+ for (const global of insights.manAdvantageConversions) {
377
+ const teamStates = insights.teamManAdvantageConversions.flatMap((team) =>
378
+ team.states.filter(
379
+ (state) => state.advantageAlive === global.advantageAlive && state.disadvantageAlive === global.disadvantageAlive
380
+ )
381
+ );
382
+ expect(teamStates.reduce((sum, state) => sum + state.advantageOpportunities, 0)).toBe(global.opportunities);
383
+ expect(teamStates.reduce((sum, state) => sum + state.advantageWins, 0)).toBe(global.advantageWins);
384
+ expect(teamStates.reduce((sum, state) => sum + state.disadvantageOpportunities, 0)).toBe(global.opportunities);
385
+ expect(teamStates.reduce((sum, state) => sum + state.disadvantageWins, 0)).toBe(global.disadvantageWins);
386
+ }
387
+
388
+ const teamStateKeys = insights.teamEconomySummaries.flatMap((team) =>
389
+ team.manAdvantage.states.map((state) => `${state.advantageAlive}:${state.disadvantageAlive}`)
390
+ );
391
+ expect(new Set(teamStateKeys)).toEqual(new Set(["5:4", "5:3"]));
392
+ expect(teamStateKeys).not.toContain("4:5");
393
+ expect(teamStateKeys).not.toContain("3:5");
394
+ });
395
+
396
+ it("ignores a post-round tail kill for manpower conversion detection", () => {
397
+ const players = [
398
+ ...Array.from({ length: 5 }, (_, index) => ({ steamId64: `a${index + 1}`, name: `A${index + 1}`, teamKey: "teamA" as const })),
399
+ ...Array.from({ length: 5 }, (_, index) => ({ steamId64: `b${index + 1}`, name: `B${index + 1}`, teamKey: "teamB" as const })),
400
+ ];
401
+ const base: TournamentFacts = {
402
+ matchId: "tail-regression",
403
+ mapName: "de_ancient",
404
+ teams: { teamA: "Alpha", teamB: "Bravo" },
405
+ players,
406
+ kills: [],
407
+ rounds: [{
408
+ roundNumber: 1,
409
+ winnerSide: "t",
410
+ winnerTeamKey: "teamA",
411
+ teamAEconomy: "full",
412
+ teamBEconomy: "full",
413
+ teamASide: "t",
414
+ teamBSide: "ct",
415
+ freezeEndTick: 100,
416
+ endTick: 200,
417
+ }],
418
+ };
419
+ const withTail: TournamentFacts = {
420
+ ...base,
421
+ kills: [{ roundNumber: 1, tick: 201, killerSteamId64: "a1", victimSteamId64: "b1", weapon: "ak47", headshot: false }],
422
+ };
423
+ const withoutTail = buildTournamentInsightsFromFacts([base]);
424
+ const tailed = buildTournamentInsightsFromFacts([withTail]);
425
+
426
+ expect(tailed.manAdvantageConversions).toEqual(withoutTail.manAdvantageConversions);
427
+ expect(tailed.teamManAdvantageConversions).toEqual(withoutTail.teamManAdvantageConversions);
428
+ expect(tailed.teamEconomySummaries.map((team) => team.manAdvantage)).toEqual(
429
+ withoutTail.teamEconomySummaries.map((team) => team.manAdvantage),
430
+ );
431
+ });
432
+
433
+ it("builds team economy summaries with maps, round win rate and sample counts", async () => {
434
+ const pkg = await fixture;
435
+ const insights = buildTournamentInsights([{ matchId: "m1", pkg }]);
436
+ const teamAName = pkg.match.teamA.name ?? "Team A";
437
+ const teamBName = pkg.match.teamB.name ?? "Team B";
438
+
439
+ const teamA = insights.teamEconomySummaries.find((row) => row.teamName === teamAName);
440
+ const teamB = insights.teamEconomySummaries.find((row) => row.teamName === teamBName);
441
+ expect(teamA).toBeDefined();
442
+ expect(teamB).toBeDefined();
443
+ expect(teamA?.maps).toBe(1);
444
+ expect(teamB?.maps).toBe(1);
445
+ expect(teamA?.rounds).toBe(pkg.rounds.length);
446
+ expect(teamB?.rounds).toBe(pkg.rounds.length);
447
+ expect((teamA?.roundWins ?? 0) + (teamB?.roundWins ?? 0)).toBe(pkg.rounds.length);
448
+ expect(teamA!.pistol.rounds).toBe(teamA!.pistol.wins + teamB!.pistol.wins);
449
+ expect(teamA!.pistol.winRatePercent).toBe(
450
+ Math.round((teamA!.pistol.wins / teamA!.pistol.rounds) * 1000) / 10
451
+ );
452
+ if (teamA!.round2.conversionRounds > 0) {
453
+ expect(teamA!.round2.conversionPercent).toBe(
454
+ Math.round((teamA!.round2.conversionWins / teamA!.round2.conversionRounds) * 1000) / 10
455
+ );
456
+ }
457
+ expect(teamA?.manAdvantage.states.length).toBeGreaterThan(0);
458
+ });
459
+ });
460
+
461
+ function expectedManAdvantageRows(pkg: Awaited<typeof fixture>) {
462
+ const targetPairs = new Map(["5:4", "5:3"].map((key) => [key, {
463
+ advantageAlive: Number(key[0]),
464
+ disadvantageAlive: Number(key[2]),
465
+ opportunities: 0,
466
+ advantageWins: 0,
467
+ disadvantageWins: 0
468
+ }]));
469
+ const playersByTeam = {
470
+ teamA: new Set(pkg.players.filter((player) => player.teamKey === "teamA").map((player) => player.steamId64)),
471
+ teamB: new Set(pkg.players.filter((player) => player.teamKey === "teamB").map((player) => player.steamId64))
472
+ };
473
+ const killsByRound = new Map<number, typeof pkg.kills>();
474
+ for (const kill of pkg.kills) {
475
+ const rows = killsByRound.get(kill.roundNumber) ?? [];
476
+ rows.push(kill);
477
+ killsByRound.set(kill.roundNumber, rows);
478
+ }
479
+
480
+ for (const round of pkg.rounds) {
481
+ const alive = {
482
+ teamA: new Set(playersByTeam.teamA),
483
+ teamB: new Set(playersByTeam.teamB)
484
+ };
485
+ const seen = new Set<string>();
486
+ const kills = [...(killsByRound.get(round.roundNumber) ?? [])].sort((a, b) => a.tick - b.tick);
487
+ for (const kill of kills) {
488
+ const victimPlayer = pkg.players[kill.victimIndex];
489
+ if (!victimPlayer) continue;
490
+ alive[victimPlayer.teamKey].delete(victimPlayer.steamId64);
491
+ const a = alive.teamA.size;
492
+ const b = alive.teamB.size;
493
+ const high = Math.max(a, b);
494
+ const low = Math.min(a, b);
495
+ const key = `${high}:${low}`;
496
+ const row = targetPairs.get(key);
497
+ if (!row || seen.has(key) || a === b) continue;
498
+ seen.add(key);
499
+ row.opportunities += 1;
500
+ const advantageTeam = a > b ? "teamA" : "teamB";
501
+ if (round.winnerTeamKey === advantageTeam) row.advantageWins += 1;
502
+ else row.disadvantageWins += 1;
503
+ }
504
+ }
505
+ return [...targetPairs.values()];
506
+ }
507
+
508
+ describe("buildMatchReportMarkdown", () => {
509
+ it("renders a markdown report with scoreboard and rounds", async () => {
510
+ const model = await workspaceFixture;
511
+ const md = buildMatchReportMarkdown(model);
512
+
513
+ expect(md).toContain(`# ${model.title}`);
514
+ expect(md).toContain("## 记分板");
515
+ expect(md).toContain("## 关键回合");
516
+ // 每个选手一行
517
+ for (const row of model.scoreboard) {
518
+ expect(md).toContain(row.name);
519
+ }
520
+ expect(md.split("\n").filter((line) => line.startsWith("| R")).length).toBe(model.rounds.length);
521
+ });
522
+ });