@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.
- package/LICENSE +7 -0
- package/package.json +8 -7
- package/src/double-awp.test.ts +29 -0
- package/src/double-awp.ts +50 -0
- package/src/duel.test.ts +190 -0
- package/src/duel.ts +359 -0
- package/src/findings.test.ts +62 -0
- package/src/findings.ts +174 -0
- package/src/index.test.ts +41 -8
- package/src/index.ts +90 -6
- package/src/insights.test.ts +522 -0
- package/src/insights.ts +1151 -0
- package/src/leaderboard.test.ts +48 -74
- package/src/map-roles.test.ts +131 -0
- package/src/map-roles.ts +219 -0
- package/src/player-map-pool.ts +28 -0
- package/src/player.test.ts +139 -110
- package/src/player.ts +66 -28
- package/src/radar-field.ts +121 -0
- package/src/replay-clock.test.ts +33 -0
- package/src/replay-clock.ts +61 -0
- package/src/season-metrics.ts +5 -0
- package/src/season-validation.test.ts +28 -8
- package/src/series.test.ts +7 -23
- package/src/series.ts +0 -19
- package/src/tactical-labels.ts +80 -0
- package/src/team.test.ts +19 -67
- package/src/team.ts +307 -127
- package/src/test-fixtures.ts +191 -0
- package/src/tournament-compat.ts +591 -0
- package/src/trails.test.ts +63 -0
- package/src/trails.ts +119 -0
- package/src/weapons.ts +1 -1
- package/src/workspace-utils.ts +2 -12
- package/src/workspace.ts +395 -83
- package/src/labels.ts +0 -3
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readdir, readFile } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import JSZip from "jszip";
|
|
9
10
|
import { describe, expect, it } from "vitest";
|
|
10
11
|
import { loadDemoPackageFromZip } from "@cs2dak/core";
|
|
11
12
|
import { buildSeasonCohort } from "@cs2dak/cohort";
|
|
@@ -13,8 +14,12 @@ import { buildSeasonLeaderboardModel, buildAllPlayerSeasonProfiles } from "./ind
|
|
|
13
14
|
|
|
14
15
|
const ZIP_DIR = fileURLToPath(new URL("../../../fixtures/output/nju-rivals-2026", import.meta.url));
|
|
15
16
|
const REPORT_FILE = fileURLToPath(new URL("../../../fixtures/output/_c-phase-report.txt", import.meta.url));
|
|
16
|
-
const integrationTimeoutMs =
|
|
17
|
+
const integrationTimeoutMs = 180_000;
|
|
17
18
|
const reportLines: string[] = [];
|
|
19
|
+
type SeasonDemoInput = {
|
|
20
|
+
matchId: string;
|
|
21
|
+
pkg: Awaited<ReturnType<typeof loadDemoPackageFromZip>>;
|
|
22
|
+
};
|
|
18
23
|
|
|
19
24
|
function report(msg: string) {
|
|
20
25
|
reportLines.push(msg);
|
|
@@ -32,13 +37,24 @@ async function dirExists(path: string): Promise<boolean> {
|
|
|
32
37
|
|
|
33
38
|
async function njuCohort() {
|
|
34
39
|
const names = (await readdir(ZIP_DIR)).filter((n) => n.endsWith(".zip")).sort();
|
|
35
|
-
const demos =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const demos: SeasonDemoInput[] = (
|
|
41
|
+
await Promise.all(
|
|
42
|
+
names.map(async (name) => {
|
|
43
|
+
const buf = await readFile(join(ZIP_DIR, name));
|
|
44
|
+
// 跳过旧版 ZIP(需要 cs2df 重导为 v3 后方可加载)
|
|
45
|
+
try {
|
|
46
|
+
const zip = await JSZip.loadAsync(buf);
|
|
47
|
+
const manifest = JSON.parse(await zip.file("manifest.json")!.async("string"));
|
|
48
|
+
if (!manifest?.schemaVersion?.startsWith("cs2-demo-format/3.")) return null;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
return {
|
|
51
|
+
matchId: name.replace(/\.zip$/, ""),
|
|
52
|
+
pkg: await loadDemoPackageFromZip(buf),
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
).filter((demo): demo is SeasonDemoInput => demo !== null);
|
|
57
|
+
return { bundle: buildSeasonCohort(demos), matchCount: demos.length };
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
describe("55-ZIP season verification", () => {
|
|
@@ -50,6 +66,10 @@ describe("55-ZIP season verification", () => {
|
|
|
50
66
|
return;
|
|
51
67
|
}
|
|
52
68
|
const { bundle, matchCount } = await njuCohort();
|
|
69
|
+
if (matchCount < 50) {
|
|
70
|
+
report(`v3 ZIP 数量不足(${matchCount}/50),跳过 55 场验证(需用 cs2df 重导所有 demo)`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
53
73
|
expect(matchCount).toBeGreaterThanOrEqual(50);
|
|
54
74
|
expect(bundle.players.length).toBeGreaterThan(30);
|
|
55
75
|
|
package/src/series.test.ts
CHANGED
|
@@ -2,33 +2,17 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import { loadDemoPackageFromZip } from "@cs2dak/core";
|
|
5
|
-
import {
|
|
6
|
-
import { buildMatchWorkspaceModel, buildSeriesSummary
|
|
5
|
+
import { seriesSummarySchema } from "@cs2dak/contract";
|
|
6
|
+
import { buildMatchWorkspaceModel, buildSeriesSummary } from "./index";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/
|
|
8
|
+
const workspaceFixture = (async () => {
|
|
9
|
+
const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)));
|
|
10
10
|
return buildMatchWorkspaceModel(await loadDemoPackageFromZip(zip));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe("recommendMatchMvp", () => {
|
|
14
|
-
it("ranks candidates from accountRR, HLTV Rating 2.0 and confidence", async () => {
|
|
15
|
-
const model = await buildWorkspace();
|
|
16
|
-
const recommendation = recommendMatchMvp(model);
|
|
17
|
-
|
|
18
|
-
expect(() => mvpRecommendationSchema.parse(recommendation)).not.toThrow();
|
|
19
|
-
expect(recommendation.candidates.length).toBeGreaterThan(0);
|
|
20
|
-
expect(recommendation.recommended.playerKey).toBe(recommendation.candidates[0]?.playerKey);
|
|
21
|
-
expect(recommendation.candidates.map((row) => row.recommendationScore)).toEqual(
|
|
22
|
-
[...recommendation.candidates.map((row) => row.recommendationScore)].sort((a, b) => b - a)
|
|
23
|
-
);
|
|
24
|
-
expect(recommendation.recommended.explanation).toHaveLength(3);
|
|
25
|
-
expect(recommendation).not.toHaveProperty("winnerUserId");
|
|
26
|
-
});
|
|
27
|
-
});
|
|
11
|
+
})();
|
|
28
12
|
|
|
29
13
|
describe("buildSeriesSummary", () => {
|
|
30
14
|
it("aggregates counts and round-weighted rates across maps", async () => {
|
|
31
|
-
const model = await
|
|
15
|
+
const model = await workspaceFixture;
|
|
32
16
|
const summary = buildSeriesSummary([
|
|
33
17
|
{ matchId: "map-1", model },
|
|
34
18
|
{ matchId: "map-2", model }
|
|
@@ -55,7 +39,7 @@ describe("buildSeriesSummary", () => {
|
|
|
55
39
|
});
|
|
56
40
|
|
|
57
41
|
it("accepts an external team map instead of owning team identity", async () => {
|
|
58
|
-
const model = await
|
|
42
|
+
const model = await workspaceFixture;
|
|
59
43
|
const renamed = {
|
|
60
44
|
...model,
|
|
61
45
|
teams: {
|
package/src/series.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
mvpRecommendationSchema,
|
|
3
2
|
seriesSummarySchema,
|
|
4
3
|
type MatchWorkspaceModel,
|
|
5
4
|
type MvpCandidate,
|
|
6
|
-
type MvpRecommendation,
|
|
7
5
|
type SeriesMatchInput,
|
|
8
6
|
type SeriesPlayerRow,
|
|
9
7
|
type SeriesSummary
|
|
@@ -48,23 +46,6 @@ function rankMvpCandidates(sources: MvpSource[]): MvpCandidate[] {
|
|
|
48
46
|
.slice(0, MVP_CANDIDATE_LIMIT);
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
export function recommendMatchMvp(model: MatchWorkspaceModel): MvpRecommendation {
|
|
52
|
-
const candidates = rankMvpCandidates(model.scoreboard.map((row) => ({
|
|
53
|
-
playerKey: row.steamId64,
|
|
54
|
-
name: row.name,
|
|
55
|
-
teamName: model.teams[row.teamKey].name,
|
|
56
|
-
rivalhubRR: row.accountRR,
|
|
57
|
-
hltvRating: row.rr,
|
|
58
|
-
confidence: row.confidence
|
|
59
|
-
})));
|
|
60
|
-
if (candidates.length === 0) throw new Error("match MVP recommendation requires at least one player");
|
|
61
|
-
return mvpRecommendationSchema.parse({
|
|
62
|
-
version: "cs2-demo-analysis-kit/mvp-recommendation-0.1",
|
|
63
|
-
recommended: candidates[0],
|
|
64
|
-
candidates
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
49
|
interface SeriesAccumulator {
|
|
69
50
|
playerKey: string;
|
|
70
51
|
name: string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { calloutCn, entryRouteCn, SITE_ENTRY_SEMANTICS, DEFAULT_POSITION_GROUPS } from "@cs2dak/maps";
|
|
2
|
+
|
|
3
|
+
export type EconomyEntry = "pistol" | "gun" | "anti_eco" | "force" | "semi" | "eco";
|
|
4
|
+
|
|
5
|
+
export interface TacticalClusterLabelInput {
|
|
6
|
+
mapName: string;
|
|
7
|
+
side: "t" | "ct";
|
|
8
|
+
economyEntry: EconomyEntry;
|
|
9
|
+
openingIntent: {
|
|
10
|
+
regionCounts: { a: number; b: number; mid: number; unknown: number };
|
|
11
|
+
spread: string;
|
|
12
|
+
};
|
|
13
|
+
positionGroupCounts: Readonly<Record<string, number>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ECONOMY_ENTRY_CN: Record<EconomyEntry, string> = {
|
|
17
|
+
pistol: "手枪局",
|
|
18
|
+
gun: "长枪局",
|
|
19
|
+
anti_eco: "Anti-eco",
|
|
20
|
+
force: "强起",
|
|
21
|
+
semi: "半起",
|
|
22
|
+
eco: "Eco",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function sideLabel(side: string): string {
|
|
26
|
+
return side === "t" ? "进攻方" : "防守方";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function chokeCn(mapName: string, chokeId: string): string {
|
|
30
|
+
const def = SITE_ENTRY_SEMANTICS[mapName]?.entries.find((entry) => entry.id === chokeId);
|
|
31
|
+
for (const callout of def?.entryCallouts ?? []) {
|
|
32
|
+
const cn = calloutCn(mapName, callout);
|
|
33
|
+
if (cn) return cn;
|
|
34
|
+
}
|
|
35
|
+
return chokeId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function structuralEntryName(mapName: string, combo: string): string {
|
|
39
|
+
const ids = combo.split("+");
|
|
40
|
+
const names = ids.map((id) => chokeCn(mapName, id));
|
|
41
|
+
if (ids.length === 1) return names[0]!;
|
|
42
|
+
return names.join(" + ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 进点路线只用于 evidence 文案;词典不会参与主簇命名。 */
|
|
46
|
+
export function formatEntryEvidenceLabel(mapName: string, site: "a" | "b", combo: string): string {
|
|
47
|
+
return entryRouteCn(mapName, site, combo) || structuralEntryName(mapName, combo);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 兼容旧展示调用;不得将此函数用于主簇名称。 */
|
|
51
|
+
export function formatEntryTacticName(mapName: string, site: "a" | "b", combo: string | null): string {
|
|
52
|
+
return combo ? formatEntryEvidenceLabel(mapName, site, combo) : `${site.toUpperCase()} 点进点`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formationLabel(input: TacticalClusterLabelInput): string {
|
|
56
|
+
const { a, mid, b } = input.openingIntent.regionCounts;
|
|
57
|
+
return `${a}A-${mid}中-${b}B`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function positionGroupLabels(input: TacticalClusterLabelInput): string[] {
|
|
61
|
+
const groups = DEFAULT_POSITION_GROUPS[input.mapName]?.[input.side].groups ?? {};
|
|
62
|
+
return Object.entries(input.positionGroupCounts)
|
|
63
|
+
.filter(([, count]) => count > 0)
|
|
64
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
+
.map(([id, count]) => `${groups[id]?.name ?? id}×${count}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 左栏短名显式保留 formation,禁止用字符串 regex 截断完整名称。 */
|
|
69
|
+
export function formatTacticalClusterShortName(cluster: TacticalClusterLabelInput): string {
|
|
70
|
+
const groups = positionGroupLabels(cluster);
|
|
71
|
+
const structure = groups.length > 0 ? groups.join(" / ") : formationLabel(cluster);
|
|
72
|
+
return `${formationLabel(cluster)} · ${structure}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 主簇名只描述已审核的默认位资产与真实开局人数结构。 */
|
|
76
|
+
export function formatTacticalClusterName(cluster: TacticalClusterLabelInput): string {
|
|
77
|
+
const econ = ECONOMY_ENTRY_CN[cluster.economyEntry];
|
|
78
|
+
const side = cluster.side === "ct" ? "CT " : "";
|
|
79
|
+
return `${side}${econ} · ${formatTacticalClusterShortName(cluster)}`;
|
|
80
|
+
}
|
package/src/team.test.ts
CHANGED
|
@@ -1,77 +1,29 @@
|
|
|
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";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
5
4
|
import { loadDemoPackageFromZip } from "@cs2dak/core";
|
|
6
|
-
import {
|
|
7
|
-
import { teamCohortSummarySchema } from "@cs2dak/contract";
|
|
8
|
-
import { buildTeamCohortSummary } 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;
|
|
5
|
+
import { buildTeamComparison, buildTeamComparisonFromFacts, buildTeamOverviewFromFacts, extractTeamComparisonFacts } from "./index";
|
|
13
6
|
|
|
14
|
-
async function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
names.map(async (name) => ({
|
|
18
|
-
matchId: name.replace(/\.zip$/, ""),
|
|
19
|
-
pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
|
|
20
|
-
}))
|
|
7
|
+
async function loadFixture() {
|
|
8
|
+
const zip = await readFile(
|
|
9
|
+
fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
|
|
21
10
|
);
|
|
22
|
-
return
|
|
11
|
+
return loadDemoPackageFromZip(zip);
|
|
23
12
|
}
|
|
24
13
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
14
|
+
describe("buildTeamComparison", () => {
|
|
15
|
+
it("builds the same model from persisted comparison facts", async () => {
|
|
16
|
+
const pkg = await loadFixture();
|
|
17
|
+
const inputs = [{ matchId: "m1", pkg }];
|
|
29
18
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"builds a product-neutral team summary from an externally supplied roster",
|
|
33
|
-
async () => {
|
|
34
|
-
const bundle = await getCohort();
|
|
35
|
-
const roster = bundle.players.slice(0, 5);
|
|
36
|
-
const summary = buildTeamCohortSummary(bundle, {
|
|
37
|
-
teamKey: "rivals-alpha",
|
|
38
|
-
name: "Rivals Alpha",
|
|
39
|
-
playerKeys: roster.map((player) => player.playerKey)
|
|
40
|
-
});
|
|
19
|
+
expect(buildTeamComparisonFromFacts(inputs.map(extractTeamComparisonFacts))).toEqual(buildTeamComparison(inputs));
|
|
20
|
+
});
|
|
41
21
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
expect(summary.members).toHaveLength(5);
|
|
46
|
-
expect(summary.coreMembers).toHaveLength(5);
|
|
47
|
-
expect(summary.averages.rivalhubRR).toBeCloseTo(
|
|
48
|
-
roster.reduce((sum, player) => sum + player.accountRR, 0) / roster.length,
|
|
49
|
-
2
|
|
50
|
-
);
|
|
51
|
-
expect(summary.leaders.map((leader) => leader.metric)).toEqual([
|
|
52
|
-
"rivalhubRR",
|
|
53
|
-
"adr",
|
|
54
|
-
"kast",
|
|
55
|
-
"firstKillPer100"
|
|
56
|
-
]);
|
|
57
|
-
expect(summary.performance.firstKills).toBeGreaterThanOrEqual(0);
|
|
58
|
-
expect(summary.performance.firstDeaths).toBeGreaterThanOrEqual(0);
|
|
59
|
-
expect(summary.performance.openingDuelWinRate).toBeGreaterThanOrEqual(0);
|
|
60
|
-
expect(summary.performance.clutchAttempts).toBeGreaterThanOrEqual(summary.performance.clutchWins);
|
|
61
|
-
expect(summary.roleComplementarity.coverageScore).toBeGreaterThanOrEqual(0);
|
|
62
|
-
expect(summary.roleComplementarity.coverageScore).toBeLessThanOrEqual(100);
|
|
63
|
-
expect(summary).not.toHaveProperty("userId");
|
|
64
|
-
},
|
|
65
|
-
integrationTimeoutMs
|
|
66
|
-
);
|
|
22
|
+
it("builds a descriptive overview for one team from persisted facts", async () => {
|
|
23
|
+
const pkg = await loadFixture();
|
|
24
|
+
const overview = buildTeamOverviewFromFacts([extractTeamComparisonFacts({ matchId: "m1", pkg })], "Team Spirit");
|
|
67
25
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
buildTeamCohortSummary(bundle, { teamKey: "empty", name: "Empty", playerKeys: [] })
|
|
72
|
-
).toThrow(/at least one/);
|
|
73
|
-
expect(() =>
|
|
74
|
-
buildTeamCohortSummary(bundle, { teamKey: "unknown", name: "Unknown", playerKeys: ["missing"] })
|
|
75
|
-
).toThrow(/not found/);
|
|
76
|
-
}, integrationTimeoutMs);
|
|
26
|
+
expect(overview).toMatchObject({ teamName: "Team Spirit", matchCount: 1, maps: [{ mapName: "de_ancient", matches: 1 }] });
|
|
27
|
+
expect(overview?.roster.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
77
29
|
});
|