@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,84 +1,58 @@
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 { leaderboardMetricKeySchema, seasonLeaderboardModelSchema } from "@cs2dak/contract";
8
3
  import { buildSeasonLeaderboardModel } from "./index";
9
-
10
- const fixtureDir = fileURLToPath(new URL("../../../fixtures/input/cohort", import.meta.url));
11
- const integrationTimeoutMs = 20_000;
4
+ import { buildTestSeasonCohortBundle } from "./test-fixtures";
12
5
 
13
6
  const ALL_METRIC_KEYS = leaderboardMetricKeySchema.options;
14
7
 
15
- async function buildCohort() {
16
- const names = (await readdir(fixtureDir)).filter((name) => name.endsWith(".zip")).sort();
17
- const demos = await Promise.all(
18
- names.map(async (name) => ({
19
- matchId: name.replace(/\.zip$/, ""),
20
- pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
21
- }))
22
- );
23
- return buildSeasonCohort(demos);
24
- }
25
-
26
8
  describe("buildSeasonLeaderboardModel", () => {
27
- it(
28
- "derives a product-neutral leaderboard model from a season cohort bundle",
29
- async () => {
30
- const bundle = await buildCohort();
31
- const model = buildSeasonLeaderboardModel(bundle);
32
-
33
- // schema 自校验 + 元信息透传
34
- expect(() => seasonLeaderboardModelSchema.parse(model)).not.toThrow();
35
- expect(model.version).toBe("cs2-demo-analysis-kit/leaderboard-0.1");
36
- expect(model.matchCount).toBe(bundle.matchCount);
37
- expect(model.weightsVersion).toBe(bundle.weightsVersion);
38
- expect(model.provenance).toEqual(bundle.provenance);
39
-
40
- // 视图:core/impact/advanced,无独立 Demo tab
41
- expect(model.views.map((v) => v.key)).toEqual(["core", "impact", "advanced"]);
42
- for (const view of model.views) {
43
- // 默认排序列必须真实存在于该视图
44
- expect(view.columns.some((c) => c.key === view.defaultSort)).toBe(true);
9
+ it("derives a product-neutral leaderboard model from a season cohort bundle", () => {
10
+ const bundle = buildTestSeasonCohortBundle();
11
+ const model = buildSeasonLeaderboardModel(bundle);
12
+
13
+ // schema 自校验 + 元信息透传
14
+ expect(() => seasonLeaderboardModelSchema.parse(model)).not.toThrow();
15
+ expect(model.version).toBe("cs2-demo-analysis-kit/leaderboard-0.1");
16
+ expect(model.matchCount).toBe(bundle.matchCount);
17
+ expect(model.weightsVersion).toBe(bundle.weightsVersion);
18
+ expect(model.provenance).toEqual(bundle.provenance);
19
+
20
+ // 视图:core/impact/advanced,无独立 Demo tab
21
+ expect(model.views.map((v) => v.key)).toEqual(["core", "impact", "advanced"]);
22
+ for (const view of model.views) {
23
+ // 默认排序列必须真实存在于该视图
24
+ expect(view.columns.some((c) => c.key === view.defaultSort)).toBe(true);
25
+ }
26
+
27
+ // 每行覆盖全部玩家,且每个指标 key 都有条目(可为 null,但不缺)
28
+ expect(model.rows).toHaveLength(bundle.players.length);
29
+ for (const row of model.rows) {
30
+ for (const key of ALL_METRIC_KEYS) {
31
+ expect(row.metrics).toHaveProperty(key);
45
32
  }
46
-
47
- // 每行覆盖全部玩家,且每个指标 key 都有条目(可为 null,但不缺)
48
- expect(model.rows).toHaveLength(bundle.players.length);
49
- for (const row of model.rows) {
50
- for (const key of ALL_METRIC_KEYS) {
51
- expect(row.metrics).toHaveProperty(key);
52
- }
53
- // 中立性:不暴露数据库/路由字段,只有中立 key
54
- expect(row).not.toHaveProperty("userId");
55
- }
56
- },
57
- integrationTimeoutMs
58
- );
59
-
60
- it(
61
- "preserves null instead of coercing to 0, and matches cohort source values",
62
- async () => {
63
- const bundle = await buildCohort();
64
- const model = buildSeasonLeaderboardModel(bundle);
65
-
66
- const byKey = new Map(model.rows.map((row) => [row.playerKey, row]));
67
- for (const player of bundle.players) {
68
- const row = byKey.get(player.playerKey)!;
69
- // 不重算:rate 字段直接透传 cohort 已重算的值
70
- expect(row.metrics.rivalhubRR).toBe(player.accountRR);
71
- expect(row.metrics.hltvRating).toBe(player.rrV1);
72
- expect(row.metrics.adr).toBe(player.indicators.adr);
73
- expect(row.metrics.kast).toBe(player.indicators.kast);
74
- // K/D:deaths=0 必须为 null 而非 0/Infinity
75
- if (player.indicators.deaths === 0) {
76
- expect(row.metrics.kd).toBeNull();
77
- } else {
78
- expect(row.metrics.kd).toBeCloseTo(player.indicators.kills / player.indicators.deaths, 1);
79
- }
33
+ // 中立性:不暴露数据库/路由字段,只有中立 key
34
+ expect(row).not.toHaveProperty("userId");
35
+ }
36
+ });
37
+
38
+ it("preserves null instead of coercing to 0, and matches cohort source values", () => {
39
+ const bundle = buildTestSeasonCohortBundle();
40
+ const model = buildSeasonLeaderboardModel(bundle);
41
+
42
+ const byKey = new Map(model.rows.map((row) => [row.playerKey, row]));
43
+ for (const player of bundle.players) {
44
+ const row = byKey.get(player.playerKey)!;
45
+ // 不重算:rate 字段直接透传 cohort 已重算的值
46
+ expect(row.metrics.rivalhubRR).toBe(player.accountRR);
47
+ expect(row.metrics.hltvRating).toBe(player.rrV1);
48
+ expect(row.metrics.adr).toBe(player.indicators.adr);
49
+ expect(row.metrics.kast).toBe(player.indicators.kast);
50
+ // K/D:deaths=0 必须为 null 而非 0/Infinity
51
+ if (player.indicators.deaths === 0) {
52
+ expect(row.metrics.kd).toBeNull();
53
+ } else {
54
+ expect(row.metrics.kd).toBeCloseTo(player.indicators.kills / player.indicators.deaths, 1);
80
55
  }
81
- },
82
- integrationTimeoutMs
83
- );
56
+ }
57
+ });
84
58
  });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PlayerMapRoleEvidence, TeamMapResponsibilityEvidence } from "@cs2dak/contract";
3
+ import { buildPlayerMapRoleProfiles, buildTeamMapRoleMatrices } from "./index.js";
4
+
5
+ function evidence(playerKey: string, overrides: Partial<PlayerMapRoleEvidence> = {}): PlayerMapRoleEvidence {
6
+ return {
7
+ version: 4, playerKey, teamKey: "Team One", mapName: "de_ancient", side: "ct", status: "ready", confidence: 0.9,
8
+ sample: { observedRounds: 16, eligibleRounds: 16, eligibleSeconds: 320, matchCount: 3, dataQuality: 1, coverage: 1 },
9
+ matchIds: ["m1", "m2", "m3"],
10
+ positionGroups: [{ positionGroupId: "a_anchor", seconds: 120, share: 0.7, roundCount: 7 }],
11
+ spatial: { dominantGroupStability: 0.875, teamRelativeGroupShare: 0.3, isolationSeconds: 4, isolationShare: 0.03, rejoinCount: 0, delayedConvergenceRoundShare: 0, movementSync: 0.6, openingMainComponentShare: 0.4, openingNoUniqueCoreShare: 0, openingIsolatedShare: 0.4, formationShares: { "4+1": 1 } },
12
+ support: { utilityUses: 0, openingUtilityUses: 0, utilityUsePerRound: 0, openingUtilityUsePerRound: 0 },
13
+ responsibility: "anchor_tendency", modifiers: ["positionally_stable"],
14
+ awp: { duty: "primary_awper", eligibleRounds: 16, qualifiedLongGunRounds: 16, freezeOwnershipRounds: 12, activeSeconds: 160, shots: 24, kills: 10, teamActiveShare: 0.8, usageConcentration: 0.8, matchConsistency: 1 },
15
+ representativeRounds: [{ matchId: "m1", roundNumber: 1, positionGroupId: "a_anchor" }, { matchId: "m2", roundNumber: 1 }, { matchId: "m3", roundNumber: 1 }], basis: ["facts"], limitations: ["approximation"],
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("map role presentation", () => {
21
+ it("never infers IGL and retains a conflicting declaration beside AWPer inference", () => {
22
+ const profiles = buildPlayerMapRoleProfiles([evidence("p1"), evidence("p1", { mapName: "de_mirage" })], [{ kind: "main_role", playerKey: "p1", role: "igl", priority: "primary", source: "user", provenance: "coach roster" }]);
23
+ expect(profiles[0]).toMatchObject({ inferredPrimaryRole: "awper", headlineRole: "IGL / AWPer" });
24
+ expect(profiles[0]?.declaredRoles).toHaveLength(1);
25
+ expect(profiles[0]?.inferredPrimaryRole).not.toBe("igl");
26
+ });
27
+
28
+ it("uses inferred headline without declared IGL and never lets non-IGL declarations replace it", () => {
29
+ const profile = buildPlayerMapRoleProfiles([evidence("p1"), evidence("p1", { mapName: "de_mirage" })], [{ kind: "main_role", playerKey: "p1", role: "anchor", priority: "primary", source: "trusted_metadata", provenance: "roster" }])[0]!;
30
+ expect(profile.headlineRole).toBe("awper");
31
+ expect(profile.inferredPrimaryRole).toBe("awper");
32
+ });
33
+
34
+ it("projects overlap and unstable coverage without a five-slot template", () => {
35
+ const first = evidence("p1");
36
+ const second = evidence("p2", { awp: { ...evidence("p2").awp, duty: "rifler", activeSeconds: 0, teamActiveShare: 0 }, positionGroups: [{ positionGroupId: "a_anchor", seconds: 100, share: 0.6, roundCount: 7 }] });
37
+ const team: TeamMapResponsibilityEvidence = {
38
+ version: 4, teamKey: "Team One", mapName: "de_ancient", side: "ct", status: "mixed", confidence: 0.7,
39
+ players: [first, second], positionOverlap: [{ positionGroupId: "a_anchor", playerKeys: ["p1", "p2"], share: 1 }],
40
+ positionConcentration: 1, unstableCoverage: true, representativeRounds: first.representativeRounds, basis: ["facts"], limitations: ["no slots"],
41
+ };
42
+ const profiles = buildPlayerMapRoleProfiles([first, second]);
43
+ const matrix = buildTeamMapRoleMatrices([team], profiles)[0]!;
44
+ expect(matrix).toMatchObject({ positionConcentration: 1, unstableCoverage: true });
45
+ expect(matrix.players).toHaveLength(2);
46
+ });
47
+
48
+ it("returns mixed rather than ready with a null role when candidates are not separated", () => {
49
+ const rifle = { ...evidence("p1").awp, duty: "rifler" as const, qualifiedLongGunRounds: 20, freezeOwnershipRounds: 0, activeSeconds: 0, teamActiveShare: 0, matchConsistency: 0 };
50
+ const ambiguousSpatial = { ...evidence("p1").spatial, dominantGroupStability: 0.4, teamRelativeGroupShare: 0 };
51
+ const profile = buildPlayerMapRoleProfiles([
52
+ evidence("p1", { side: "ct", responsibility: "anchor_tendency", spatial: ambiguousSpatial, awp: rifle }),
53
+ evidence("p1", { side: "t", mapName: "de_mirage", responsibility: "pack", spatial: ambiguousSpatial, awp: rifle }),
54
+ ])[0]!;
55
+ expect(profile.status).toBe("mixed");
56
+ expect(profile.inferredPrimaryRole).toBeNull();
57
+ expect(profile.runnerUpRole).not.toBeNull();
58
+ });
59
+
60
+ it("does not promote one-map AWP usage to global Primary AWPer", () => {
61
+ const profile = buildPlayerMapRoleProfiles([evidence("p1")])[0]!;
62
+ expect(profile.weaponDuty).toBe("secondary_awper");
63
+ expect(profile.weaponDuty).not.toBe("primary_awper");
64
+ });
65
+
66
+ it("keeps secondary and situational AWP as evidence rather than public headline eligibility", () => {
67
+ const secondary = { ...evidence("p1").awp, duty: "secondary_awper" as const, qualifiedLongGunRounds: 12, freezeOwnershipRounds: 4, activeSeconds: 40, shots: 6, kills: 3, teamActiveShare: 0.3, matchConsistency: 0.6 };
68
+ const anchor = buildPlayerMapRoleProfiles([evidence("p1", { awp: secondary })])[0]!;
69
+ expect(anchor.weaponDuty).toBe("secondary_awper");
70
+ expect(anchor.roleEvidenceScores.awper).toBeGreaterThan(0.55);
71
+ expect(anchor.inferredPrimaryRole).toBe("anchor");
72
+ const situational = buildPlayerMapRoleProfiles([evidence("p1", { awp: { ...secondary, duty: "situational_awper", qualifiedLongGunRounds: 1, freezeOwnershipRounds: 1 } })])[0]!;
73
+ expect(situational.inferredPrimaryRole).toBe("anchor");
74
+ });
75
+
76
+ it("keeps a reliable primary AWP eligible for the headline", () => {
77
+ const profile = buildPlayerMapRoleProfiles([evidence("p1"), evidence("p1", { mapName: "de_mirage" })])[0]!;
78
+ expect(profile.inferredPrimaryRole).toBe("awper");
79
+ expect(profile.headlineRole).toBe("awper");
80
+ });
81
+
82
+ it("uses complete match coverage rather than the capped representative evidence sample for global AWP duty", () => {
83
+ const first = evidence("p1", { representativeRounds: [{ matchId: "m1", roundNumber: 1 }] });
84
+ const second = evidence("p1", { mapName: "de_mirage", representativeRounds: [{ matchId: "m1", roundNumber: 2 }] });
85
+ expect(buildPlayerMapRoleProfiles([first, second])[0]?.weaponDuty).toBe("primary_awper");
86
+ });
87
+
88
+ it("matches declaration time scope by match occurrence time and preserves missing-time limitations", () => {
89
+ const declaration = { kind: "main_role", playerKey: "p1", role: "anchor", priority: "primary", source: "self_report", provenance: "public interview", validFrom: "2026-06-01T00:00:00.000Z", validTo: "2026-06-30T23:59:59.000Z" } as const;
90
+ expect(buildPlayerMapRoleProfiles([evidence("p1")], [declaration], { matchTimes: { m1: "2026-05-01T00:00:00.000Z", m2: "2026-05-02T00:00:00.000Z", m3: "2026-05-03T00:00:00.000Z" } })[0]?.declaredRoles).toHaveLength(0);
91
+ const missingTime = buildPlayerMapRoleProfiles([evidence("p1")], [declaration])[0]!;
92
+ expect(missingTime.declaredRoles).toHaveLength(1);
93
+ expect(missingTime.limitations).toContain("比赛时间缺失;该声明未与时间未知的 evidence 对齐。");
94
+ });
95
+
96
+ it("only aligns time-scoped declarations with rows fully inside the declared interval", () => {
97
+ const declaration = { kind: "main_role", playerKey: "p1", role: "anchor", priority: "primary", source: "self_report", provenance: "public interview", validFrom: "2026-06-01T00:00:00.000Z", validTo: "2026-06-30T23:59:59.000Z" } as const;
98
+ const before = evidence("p1", { matchIds: ["m1"], representativeRounds: [{ matchId: "m1", roundNumber: 1 }], side: "ct" });
99
+ const within = evidence("p1", { matchIds: ["m4"], representativeRounds: [{ matchId: "m4", roundNumber: 1 }], side: "t" });
100
+ const profile = buildPlayerMapRoleProfiles([before, within], [declaration], { matchTimes: { m1: "2026-05-01T00:00:00.000Z", m4: "2026-06-15T00:00:00.000Z" } })[0]!;
101
+ expect(profile.declaredRoles).toHaveLength(1);
102
+ expect(profile.roleAlignments[0]?.ctSide).toContain("unknown");
103
+
104
+ const mixed = buildPlayerMapRoleProfiles([evidence("p1", { matchIds: ["m1", "m4"] })], [declaration], { matchTimes: { m1: "2026-05-01T00:00:00.000Z", m4: "2026-06-15T00:00:00.000Z" } })[0]!;
105
+ expect(mixed.roleAlignments[0]?.sampleLimitations).toContain("聚合 evidence 跨越声明日期;为避免混入期外样本,未纳入该行。");
106
+ });
107
+
108
+ it("isolates the same player across canonical team identities and aligns declarations only with their scoped rows", () => {
109
+ const teamOne = evidence("p1", { teamKey: "Team One", mapName: "de_ancient", responsibility: "anchor_tendency" });
110
+ const teamTwo = evidence("p1", { teamKey: "Team Two", mapName: "de_mirage", side: "t", responsibility: "late_joining", awp: { ...evidence("p1").awp, duty: "rifler", activeSeconds: 0, teamActiveShare: 0, matchConsistency: 0 } });
111
+ const declaration = { kind: "main_role", playerKey: "p1", teamKey: "Team Two", mapName: "de_mirage", role: "closer", priority: "primary", source: "user", provenance: "coach" } as const;
112
+ const profiles = buildPlayerMapRoleProfiles([teamOne, teamTwo], [declaration]);
113
+ expect(profiles).toHaveLength(2);
114
+ expect(profiles.find((profile) => profile.teamKey === "Team One")?.declaredRoles).toHaveLength(0);
115
+ const second = profiles.find((profile) => profile.teamKey === "Team Two")!;
116
+ expect(second.declaredRoles).toHaveLength(1);
117
+ expect(second.roleAlignments[0]?.ctSide).toContain("unknown");
118
+ });
119
+
120
+ it("does not let a secondary IGL declaration override an AWP headline", () => {
121
+ const profile = buildPlayerMapRoleProfiles([evidence("p1"), evidence("p1", { mapName: "de_mirage" })], [{ kind: "main_role", playerKey: "p1", role: "igl", priority: "secondary", source: "user", provenance: "coach" }])[0]!;
122
+ expect(profile.headlineRole).toBe("awper");
123
+ });
124
+
125
+ it("keeps declared weapon duty independent from inferred weapon duty", () => {
126
+ const profile = buildPlayerMapRoleProfiles([evidence("p1"), evidence("p1", { mapName: "de_mirage" })], [{ kind: "weapon_duty", playerKey: "p1", weaponDuty: "secondary_awper", source: "user", provenance: "coach" }])[0]!;
127
+ expect(profile.declaredWeaponDuties[0]?.weaponDuty).toBe("secondary_awper");
128
+ expect(profile.weaponDuty).toBe("primary_awper");
129
+ expect(profile.weaponDutyAlignments[0]?.overall).toBe("different_observation");
130
+ });
131
+ });
@@ -0,0 +1,219 @@
1
+ import {
2
+ playerMapRoleProfileSchema,
3
+ teamMapRoleMatrixSchema,
4
+ type DeclaredRole,
5
+ type EvidenceRef,
6
+ type InferredMapRole,
7
+ type MainRoleDeclaration,
8
+ type MapRoleStatus,
9
+ type PlayerMapRoleEvidence,
10
+ type PlayerMapRoleProfile,
11
+ type RoleDeclaration,
12
+ type TeamMapResponsibilityEvidence,
13
+ type TeamMapRoleMatrix,
14
+ type WeaponDutyDeclaration,
15
+ type WeaponDuty,
16
+ } from "@cs2dak/contract";
17
+ import { MAP_ROLE_MODEL_VERSION, MAP_ROLE_THRESHOLDS } from "@cs2dak/cohort";
18
+ import { positionGroupDisplay } from "@cs2dak/maps";
19
+
20
+ const ROLE_ORDER: InferredMapRole[] = ["awper", "anchor", "opener", "closer"];
21
+
22
+ function rounded(value: number, digits = 3): number { return Number(value.toFixed(digits)); }
23
+ function clamp(value: number): number { return Math.max(0, Math.min(1, value)); }
24
+
25
+ function evidence(rows: PlayerMapRoleEvidence[]): EvidenceRef[] {
26
+ return rows.flatMap((row) => row.representativeRounds.map((ref) => ({ ...ref, reason: `${row.mapName} ${row.side.toUpperCase()} 的位置职责代表回合`, role: "example" as const }))).slice(0, 8);
27
+ }
28
+
29
+ function aggregateStatus(rows: PlayerMapRoleEvidence[]): MapRoleStatus {
30
+ if (rows.length === 0 || rows.every((row) => row.status === "unknown")) return "unknown";
31
+ if (rows.every((row) => row.status === "insufficient" || row.status === "unknown")) return "insufficient";
32
+ return "mixed";
33
+ }
34
+
35
+ function weighted(rows: PlayerMapRoleEvidence[], value: (row: PlayerMapRoleEvidence) => number): number {
36
+ const usable = rows.filter((row) => row.status === "ready" || row.status === "mixed");
37
+ const total = usable.reduce((sum, row) => sum + row.sample.eligibleSeconds * row.sample.dataQuality, 0);
38
+ return total === 0 ? 0 : usable.reduce((sum, row) => sum + value(row) * row.sample.eligibleSeconds * row.sample.dataQuality, 0) / total;
39
+ }
40
+
41
+ function globalWeaponDuty(rows: PlayerMapRoleEvidence[]): WeaponDuty | null {
42
+ const usable = rows.filter((row) => row.awp.activeSeconds != null);
43
+ if (usable.length === 0) return null;
44
+ const qualified = usable.reduce((sum, row) => sum + row.awp.qualifiedLongGunRounds, 0);
45
+ const freeze = usable.reduce((sum, row) => sum + row.awp.freezeOwnershipRounds, 0);
46
+ const active = usable.reduce((sum, row) => sum + (row.awp.activeSeconds ?? 0), 0);
47
+ const shots = usable.some((row) => row.awp.shots != null) ? usable.reduce((sum, row) => sum + (row.awp.shots ?? 0), 0) : null;
48
+ const kills = usable.some((row) => row.awp.kills != null) ? usable.reduce((sum, row) => sum + (row.awp.kills ?? 0), 0) : null;
49
+ const share = weighted(usable, (row) => row.awp.teamActiveShare ?? 0);
50
+ const consistency = weighted(usable, (row) => row.awp.matchConsistency ?? 0);
51
+ const matches = new Set(usable.flatMap((row) => row.matchIds)).size;
52
+ const maps = new Set(usable.filter((row) => row.awp.duty !== "rifler").map((row) => row.mapName)).size;
53
+ const ownership = qualified > 0 ? freeze / qualified : 0;
54
+ const primaryAction = active >= 60 || (shots ?? 0) >= 10 || (kills ?? 0) >= 4;
55
+ const secondaryAction = active >= 20 || (shots ?? 0) >= 4 || (kills ?? 0) >= 2;
56
+ if (qualified >= 20 && matches >= 3 && maps >= 2 && ownership >= 0.38 && share >= 0.52 && consistency >= 0.55 && primaryAction) return "primary_awper";
57
+ if (qualified >= 10 && matches >= 2 && ownership >= 0.18 && share >= 0.2 && consistency >= 0.45 && secondaryAction) return "secondary_awper";
58
+ return freeze > 0 || active > 0 ? "situational_awper" : "rifler";
59
+ }
60
+
61
+ function evidenceScores(rows: PlayerMapRoleEvidence[], weaponDuty: WeaponDuty | null): Record<InferredMapRole, number> {
62
+ const awper = weaponDuty == null ? 0 : ({ primary_awper: 1, secondary_awper: 0.72, situational_awper: 0.3, rifler: 0 } as const)[weaponDuty];
63
+ const anchor = weighted(rows.filter((row) => row.side === "ct"), (row) => {
64
+ const relative = clamp(Math.max(0, row.spatial.teamRelativeGroupShare ?? 0) / 0.3);
65
+ const positionShare = row.positionGroups[0]?.share ?? 0;
66
+ return clamp((row.spatial.dominantGroupStability ?? 0) * 0.45 + relative * 0.3 + positionShare * 0.25);
67
+ });
68
+ const opener = weighted(rows.filter((row) => row.side === "t"), (row) => clamp(
69
+ (row.spatial.openingMainComponentShare ?? 0) * 0.55
70
+ + (1 - (row.spatial.openingIsolatedShare ?? 1)) * 0.25
71
+ + (row.spatial.dominantGroupStability ?? 0) * 0.2,
72
+ ));
73
+ const closer = weighted(rows.filter((row) => row.side === "t"), (row) => clamp(
74
+ (row.spatial.openingIsolatedShare ?? 0) * 0.35
75
+ + (row.spatial.delayedConvergenceRoundShare ?? 0) * 0.4
76
+ + (row.spatial.dominantGroupStability ?? 0) * 0.25,
77
+ ));
78
+ const headlineCap = weaponDuty === "primary_awper" ? 0.75 : 1;
79
+ return { awper: rounded(awper), anchor: rounded(anchor * headlineCap), opener: rounded(opener * headlineCap), closer: rounded(closer * headlineCap) };
80
+ }
81
+
82
+ function rankScores(scores: Record<InferredMapRole, number>): Array<[InferredMapRole, number]> {
83
+ return ROLE_ORDER.map((role) => [role, scores[role]] as [InferredMapRole, number]).sort((a, b) => b[1] - a[1] || ROLE_ORDER.indexOf(a[0]) - ROLE_ORDER.indexOf(b[0]));
84
+ }
85
+
86
+ interface ScopedEvidence { rows: PlayerMapRoleEvidence[]; limitations: string[]; relevant: boolean }
87
+
88
+ function scopedEvidence(declaration: RoleDeclaration, rows: PlayerMapRoleEvidence[], matchTimes: Record<string, string | null>): ScopedEvidence {
89
+ const base = rows.filter((row) => (declaration.teamKey == null || row.teamKey === declaration.teamKey)
90
+ && (declaration.mapName == null || row.mapName === declaration.mapName));
91
+ if (base.length === 0) return { rows: [], limitations: [], relevant: false };
92
+ if (declaration.validFrom == null && declaration.validTo == null) return { rows: base, limitations: [], relevant: true };
93
+ const inScope = (matchId: string) => {
94
+ const time = matchTimes[matchId];
95
+ return time != null && (declaration.validFrom == null || time >= declaration.validFrom) && (declaration.validTo == null || time <= declaration.validTo);
96
+ };
97
+ const unknown = base.some((row) => row.matchIds.some((matchId) => matchTimes[matchId] == null));
98
+ // Cohort evidence is emitted per match, but retain this strict guard for old or
99
+ // externally supplied aggregate rows: a partially overlapping row cannot be
100
+ // attributed to a declaration's time scope without mixing out-of-scope play.
101
+ const partial = base.some((row) => row.matchIds.some(inScope) && !row.matchIds.every(inScope));
102
+ const inRange = base.filter((row) => row.matchIds.length > 0 && row.matchIds.every(inScope));
103
+ return {
104
+ rows: inRange,
105
+ limitations: [
106
+ ...(unknown ? ["比赛时间缺失;该声明未与时间未知的 evidence 对齐。"] : []),
107
+ ...(partial ? ["聚合 evidence 跨越声明日期;为避免混入期外样本,未纳入该行。"] : []),
108
+ ],
109
+ relevant: inRange.length > 0 || unknown || partial,
110
+ };
111
+ }
112
+
113
+ function alignment(declaration: MainRoleDeclaration, inferred: InferredMapRole | null, scores: Record<InferredMapRole, number>, rows: PlayerMapRoleEvidence[], scopeLimitations: string[]) {
114
+ const primary = declaration.priority === "primary" ? declaration.role : null;
115
+ const secondary = declaration.priority === "secondary" ? [declaration.role] : [];
116
+ const comparablePrimary = primary === "igl" ? null : primary;
117
+ const overall = primary == null || inferred == null ? "not_comparable"
118
+ : comparablePrimary === inferred ? "aligned"
119
+ : secondary.includes(inferred as DeclaredRole) || (comparablePrimary != null && scores[comparablePrimary] >= 0.5) ? "partially_aligned"
120
+ : "different_observation";
121
+ const top = (side: "t" | "ct") => rows.filter((row) => row.side === side).sort((a, b) => b.sample.eligibleSeconds - a.sample.eligibleSeconds)[0]?.responsibility ?? "unknown";
122
+ return {
123
+ declaration,
124
+ declaredPrimary: primary,
125
+ declaredSecondary: secondary,
126
+ inferredPrimary: inferred,
127
+ overall,
128
+ tSide: `T 方观察职责:${top("t")}`,
129
+ ctSide: `CT 方观察职责:${top("ct")}`,
130
+ disagreementReasons: overall === "different_observation" ? ["声明与当前语料中的观察重点不同;这不表示声明填写错误。"] : [],
131
+ sampleLimitations: [...new Set([...scopeLimitations, ...rows.flatMap((row) => row.limitations)])].slice(0, 4),
132
+ } as const;
133
+ }
134
+
135
+ export interface BuildPlayerMapRoleProfilesOptions { matchTimes?: Record<string, string | null> }
136
+
137
+ function infer(rows: PlayerMapRoleEvidence[]) {
138
+ const weaponDuty = globalWeaponDuty(rows);
139
+ const roleEvidenceScores = evidenceScores(rows, weaponDuty);
140
+ // AWP resemblance remains useful evidence, but only a reliable primary AWP
141
+ // duty is eligible to win the public main-role headline.
142
+ const headlineScores = { ...roleEvidenceScores, awper: weaponDuty === "primary_awper" ? roleEvidenceScores.awper : 0 };
143
+ const ranked = rankScores(headlineScores);
144
+ const [winner, winnerScore] = ranked[0]!;
145
+ const [runnerUp, runnerScore] = ranked[1]!;
146
+ const margin = rounded(winnerScore - runnerScore);
147
+ const baseStatus = aggregateStatus(rows);
148
+ const sufficient = rows.some((row) => row.status === "ready" || row.status === "mixed");
149
+ const reliableWinner = sufficient && winnerScore >= 0.55 && margin >= MAP_ROLE_THRESHOLDS.responsibilitySeparation;
150
+ const status: MapRoleStatus = baseStatus === "unknown" || baseStatus === "insufficient" ? baseStatus : reliableWinner ? "ready" : "mixed";
151
+ return { weaponDuty, roleEvidenceScores, winner, runnerUp, margin, sufficient, status, inferredPrimaryRole: status === "ready" ? winner : null };
152
+ }
153
+
154
+ export function buildPlayerMapRoleProfiles(evidenceRows: PlayerMapRoleEvidence[], declarations: RoleDeclaration[] = [], options: BuildPlayerMapRoleProfilesOptions = {}): PlayerMapRoleProfile[] {
155
+ const byPlayerTeam = new Map<string, PlayerMapRoleEvidence[]>();
156
+ for (const row of evidenceRows) {
157
+ const key = `${row.playerKey}\t${row.teamKey}`;
158
+ byPlayerTeam.set(key, [...(byPlayerTeam.get(key) ?? []), row]);
159
+ }
160
+ return [...byPlayerTeam.entries()].map(([key, rows]) => {
161
+ const [playerKey, teamKey] = key.split("\t");
162
+ const scopedDeclarations = declarations
163
+ .filter((declaration) => declaration.playerKey === playerKey && (declaration.teamKey == null || declaration.teamKey === teamKey))
164
+ .map((declaration) => ({ declaration, scope: scopedEvidence(declaration, rows, options.matchTimes ?? {}) }))
165
+ .filter((item) => item.scope.relevant);
166
+ const declaredRoles = scopedDeclarations.filter((item): item is { declaration: MainRoleDeclaration; scope: ScopedEvidence } => item.declaration.kind === "main_role").map((item) => item.declaration);
167
+ const declaredWeaponDuties = scopedDeclarations.filter((item): item is { declaration: WeaponDutyDeclaration; scope: ScopedEvidence } => item.declaration.kind === "weapon_duty").map((item) => item.declaration);
168
+ const automatic = infer(rows);
169
+ const { weaponDuty, roleEvidenceScores, winner, runnerUp, margin, sufficient, status, inferredPrimaryRole } = automatic;
170
+ const declaredIgl = scopedDeclarations.some((item) => item.declaration.kind === "main_role" && item.declaration.priority === "primary" && item.declaration.role === "igl" && item.declaration.mapName == null && item.declaration.validFrom == null && item.declaration.validTo == null);
171
+ const headlineRole = declaredIgl ? inferredPrimaryRole === "awper" ? "IGL / AWPer" : "IGL" : inferredPrimaryRole;
172
+ const volume = clamp(rows.reduce((sum, row) => sum + row.sample.eligibleRounds, 0) / 40);
173
+ const quality = weighted(rows, (row) => row.sample.dataQuality);
174
+ const stability = weighted(rows, (row) => row.awp.matchConsistency ?? row.spatial.dominantGroupStability ?? 0.5);
175
+ const confidence = status === "unknown" ? 0 : rounded(clamp(volume * 0.3 + quality * 0.3 + margin * 0.25 + stability * 0.15));
176
+ const roleAlignments = scopedDeclarations
177
+ .filter((item): item is { declaration: MainRoleDeclaration; scope: ScopedEvidence } => item.declaration.kind === "main_role")
178
+ .map(({ declaration, scope }) => {
179
+ const observed = scope.rows.length ? infer(scope.rows) : null;
180
+ return alignment(declaration, observed?.inferredPrimaryRole ?? null, observed?.roleEvidenceScores ?? roleEvidenceScores, scope.rows, scope.limitations);
181
+ });
182
+ const weaponDutyAlignments = scopedDeclarations
183
+ .filter((item): item is { declaration: WeaponDutyDeclaration; scope: ScopedEvidence } => item.declaration.kind === "weapon_duty")
184
+ .map(({ declaration, scope }) => {
185
+ const observed = scope.rows.length ? infer(scope.rows).weaponDuty : null;
186
+ return { declaration, observedWeaponDuty: observed, overall: observed == null ? "not_comparable" : observed === declaration.weaponDuty ? "aligned" : "different_observation", sampleLimitations: scope.limitations } as const;
187
+ });
188
+ return playerMapRoleProfileSchema.parse({
189
+ version: "cs2-demo-analysis-kit/player-map-role-profile-5.0", playerKey, teamKey,
190
+ declaredRoles, declaredWeaponDuties,
191
+ inferredPrimaryRole, runnerUpRole: sufficient ? runnerUp : null, separationMargin: sufficient ? margin : null, roleEvidenceScores,
192
+ headlineRole, status, confidence, weaponDuty,
193
+ positionGroupDisplay: rows.flatMap((row) => row.positionGroups.map((group) => ({ mapName: row.mapName, side: row.side, positionGroupId: group.positionGroupId, ...positionGroupDisplay(row.mapName, row.side, group.positionGroupId) }))),
194
+ roleAlignments, weaponDutyAlignments,
195
+ perMapEvidence: rows.sort((a, b) => a.teamKey.localeCompare(b.teamKey) || a.mapName.localeCompare(b.mapName) || a.side.localeCompare(b.side)),
196
+ evidence: evidence(rows),
197
+ basis: [`${MAP_ROLE_MODEL_VERSION}:headline 直接由连续 evidence 计算,不重复叠加离散职责标签。`, "倾向分不是概率;人工声明与自动观察独立保存。"],
198
+ limitations: ["IGL 无法由 demo 统计验证。", ...[...new Set(scopedDeclarations.flatMap((item) => item.scope.limitations))]],
199
+ });
200
+ }).sort((a, b) => a.playerKey.localeCompare(b.playerKey) || a.teamKey.localeCompare(b.teamKey));
201
+ }
202
+
203
+ export function buildTeamMapRoleMatrices(evidenceRows: TeamMapResponsibilityEvidence[], profiles: PlayerMapRoleProfile[]): TeamMapRoleMatrix[] {
204
+ const profileByKey = new Map(profiles.map((profile) => [`${profile.playerKey}\t${profile.teamKey}`, profile]));
205
+ return evidenceRows.map((row) => teamMapRoleMatrixSchema.parse({
206
+ version: "cs2-demo-analysis-kit/team-map-role-matrix-4.0", teamKey: row.teamKey, mapName: row.mapName, side: row.side,
207
+ status: row.status, confidence: row.confidence,
208
+ players: row.players.map((player) => ({
209
+ playerKey: player.playerKey,
210
+ primaryPositionGroups: player.positionGroups.slice(0, 3).map((group) => ({ ...group, ...positionGroupDisplay(row.mapName, row.side, group.positionGroupId) })),
211
+ responsibility: player.responsibility, modifiers: player.modifiers,
212
+ sampleRounds: player.sample.eligibleRounds, confidence: player.confidence,
213
+ weaponDuty: profileByKey.get(`${player.playerKey}\t${row.teamKey}`)?.weaponDuty ?? null, evidence: evidence([player]),
214
+ })),
215
+ positionOverlap: row.positionOverlap, positionConcentration: row.positionConcentration, unstableCoverage: row.unstableCoverage,
216
+ representativeRounds: row.representativeRounds.map((ref) => ({ ...ref, reason: `${row.mapName} ${row.side.toUpperCase()} 队伍职责代表回合`, role: "example" as const })),
217
+ basis: row.basis, limitations: row.limitations,
218
+ })).sort((a, b) => a.teamKey.localeCompare(b.teamKey) || a.mapName.localeCompare(b.mapName) || a.side.localeCompare(b.side));
219
+ }
@@ -0,0 +1,28 @@
1
+ import { playerMapPoolRowSchema, type PlayerMapPoolRow, type PlayerMapRoleProfile, type SupportedMapName } from "@cs2dak/contract";
2
+
3
+ export interface PlayerMapPerformanceInput {
4
+ mapName: SupportedMapName; matchCount: number; roundCount: number; wins: number; losses: number;
5
+ rr: number | null; adr: number | null; kast: number | null; openingKills: number; openingDeaths: number; mainWeapon: string | null;
6
+ }
7
+
8
+ export function buildPlayerMapPool(performance: PlayerMapPerformanceInput[], profile: PlayerMapRoleProfile | null): PlayerMapPoolRow[] {
9
+ return performance.map((row) => {
10
+ const roleRows = profile?.perMapEvidence.filter((evidence) => evidence.mapName === row.mapName) ?? [];
11
+ const side = (value: "t" | "ct") => roleRows.find((evidence) => evidence.side === value);
12
+ const label = (value: "t" | "ct") => {
13
+ const evidence = side(value); const group = evidence?.positionGroups[0];
14
+ if (!group) return null;
15
+ return profile?.positionGroupDisplay.find((display) => display.mapName === row.mapName && display.side === value && display.positionGroupId === group.positionGroupId)?.displayName ?? "未映射位置";
16
+ };
17
+ const qualityWeight = roleRows.reduce((sum, evidence) => sum + evidence.sample.eligibleSeconds, 0);
18
+ const weighted = (pick: (evidence: (typeof roleRows)[number]) => number) => qualityWeight === 0 ? 0 : roleRows.reduce((sum, evidence) => sum + pick(evidence) * evidence.sample.eligibleSeconds, 0) / qualityWeight;
19
+ return playerMapPoolRowSchema.parse({
20
+ ...row, winRate: row.matchCount ? row.wins / row.matchCount : null,
21
+ globalWeaponDuty: profile?.weaponDuty ?? null,
22
+ mapSideAwpUsage: roleRows.map((evidence) => ({ side: evidence.side, duty: evidence.awp.duty, qualifiedRounds: evidence.awp.qualifiedLongGunRounds, activeSeconds: evidence.awp.activeSeconds })),
23
+ tPositionGroup: label("t"), ctPositionGroup: label("ct"), tResponsibility: side("t")?.responsibility ?? "unknown", ctResponsibility: side("ct")?.responsibility ?? "unknown",
24
+ sampleQuality: weighted((evidence) => evidence.sample.dataQuality), confidence: weighted((evidence) => evidence.confidence),
25
+ evidence: roleRows.flatMap((evidence) => evidence.representativeRounds.map((ref) => ({ ...ref, reason: `${row.mapName} 地图池代表回合`, role: "example" as const }))).slice(0, 6),
26
+ });
27
+ }).sort((a, b) => b.matchCount - a.matchCount || a.mapName.localeCompare(b.mapName));
28
+ }