@cs2dak/cohort 0.2.1 → 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,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PlayerPositionRoundFact, TeamShapeRoundFact } from "@cs2dak/contract";
3
+ import { buildPlayerMapRoleEvidence, buildTeamMapResponsibilityEvidence } from "./index.js";
4
+
5
+ function row(playerIndex: number, roundNumber: number, overrides: Partial<PlayerPositionRoundFact> = {}): PlayerPositionRoundFact {
6
+ return {
7
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber, teamKey: "teamA", side: "ct",
8
+ playerIndex, steamId64: `7656119800000000${playerIndex + 1}`, eligibleSeconds: 20,
9
+ economyType: "full", openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
10
+ openingEligibleSeconds: 20, openingPositionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_anchor" : "b_anchor", seconds: 16, share: 0.8 }],
11
+ openingMeanComponentSize: 3, openingIsolationSeconds: 0, openingUtilityUseCount: 0,
12
+ openingPath: [],
13
+ positionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_anchor" : "b_anchor", seconds: 16, share: 0.8 }],
14
+ unresolvedCalloutSeconds: 0, calloutCoverage: 1, meanNearestTeammateDistance: 200, meanTeamCentroidDistance: 300,
15
+ meanComponentSize: 3, isolationSegments: [], rejoinTicks: [], delayedConvergences: [], movementSync: 0.6, utilityUseCount: 0,
16
+ freezeAwpOwnership: playerIndex === 0, activeAwpSeconds: playerIndex === 0 ? 12 : 0, awpShots: playerIndex === 0 ? 2 : 0, awpKills: playerIndex === 0 ? 1 : 0,
17
+ availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("map role evidence", () => {
23
+ it("merges identities, computes team-relative AWP duty, and keeps unsupported maps out", () => {
24
+ const rows = Array.from({ length: 6 }, (_, index) => [row(0, index + 1), row(1, index + 1)]).flat();
25
+ rows.push(row(2, 1, { mapName: "de_vertigo" }));
26
+ const options = {
27
+ identityMap: { "76561198000000001": { playerKey: "player:one" } },
28
+ teamIdentityMap: { "m1:teamA": "Team One" },
29
+ };
30
+ const players = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, options);
31
+ const awper = players.find((player) => player.playerKey === "player:one")!;
32
+ expect(players).toHaveLength(2);
33
+ expect(awper.teamKey).toBe("Team One");
34
+ expect(awper.awp.duty).toBe("secondary_awper");
35
+ expect(awper.spatial.teamRelativeGroupShare).toBeGreaterThan(0);
36
+
37
+ const teams = buildTeamMapResponsibilityEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, options);
38
+ expect(teams).toHaveLength(1);
39
+ expect(teams[0]?.players).toHaveLength(2);
40
+ });
41
+
42
+ it("keeps missing replay data unknown and null instead of making a zero-valued role", () => {
43
+ const missing = row(0, 1, {
44
+ eligibleSeconds: null, positionGroupDwell: [], activeAwpSeconds: null, awpShots: null, awpKills: null,
45
+ availability: { replay: "missing", nav: "missing", callouts: "missing", shots: "missing" },
46
+ });
47
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: [missing], teamShapeRounds: [] });
48
+ expect(evidence[0]).toMatchObject({ status: "unknown", awp: { activeSeconds: null, teamActiveShare: null } });
49
+ });
50
+
51
+ it("consumes opening component membership and formation continuity from TeamShapeRoundFact", () => {
52
+ const rows = Array.from({ length: 6 }, (_, index) => [row(0, index + 1), row(4, index + 1)]).flat();
53
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
54
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "ct",
55
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
56
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [4, 1], partition: "4+1", componentPlayerIndices: [[0, 1, 2, 3], [4]] }],
57
+ coverageSeconds: 40, windows: [{ startTick: 100, endTick: 2660, coverageSeconds: 40, componentSizes: [4, 1], partition: "4+1", componentPlayerIndices: [[0, 1, 2, 3], [4]] }],
58
+ availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
59
+ }));
60
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: shapes });
61
+ expect(evidence.find((item) => item.playerKey.endsWith("1"))?.spatial).toMatchObject({ openingMainComponentShare: 1, formationShares: { "4+1": 1 } });
62
+ expect(evidence.find((item) => item.playerKey.endsWith("5"))?.spatial.openingIsolatedShare).toBe(1);
63
+ });
64
+
65
+ it("does not assign main-component credit when the opening has no unique majority core", () => {
66
+ const rows = Array.from({ length: 6 }, (_, index) => row(0, index + 1));
67
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
68
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "ct",
69
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
70
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [2, 2, 1], partition: "2+2+1", componentPlayerIndices: [[0, 1], [2, 3], [4]] }],
71
+ coverageSeconds: 20, windows: [], availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
72
+ }));
73
+ const evidence = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: shapes })[0]!;
74
+ expect(evidence.spatial.openingMainComponentShare).toBe(0);
75
+ expect(evidence.spatial.openingNoUniqueCoreShare).toBe(1);
76
+ });
77
+
78
+ it("aggregates team matrices to one row per unique player before overlap", () => {
79
+ const sameGroup = { openingPositionGroupDwell: [{ positionGroupId: "a_anchor", seconds: 16, share: 0.8 }], positionGroupDwell: [{ positionGroupId: "a_anchor", seconds: 16, share: 0.8 }] };
80
+ const rows = Array.from({ length: 6 }, (_, index) => [
81
+ row(0, index + 1, sameGroup), row(1, index + 1, sameGroup),
82
+ row(0, index + 1, { ...sameGroup, matchId: "m2" }), row(1, index + 1, { ...sameGroup, matchId: "m2" }),
83
+ ]).flat();
84
+ const teams = buildTeamMapResponsibilityEvidence({ playerPositionRounds: rows, teamShapeRounds: [] }, { teamIdentityMap: { "m1:teamA": "Team One", "m2:teamA": "Team One" } });
85
+ expect(teams[0]?.players).toHaveLength(2);
86
+ expect(teams[0]?.positionOverlap[0]?.playerKeys).toHaveLength(2);
87
+ expect(new Set(teams[0]?.positionOverlap[0]?.playerKeys).size).toBe(2);
88
+ });
89
+
90
+ it("produces support responsibilities only from observable utility timing and team coordination", () => {
91
+ const shapes: TeamShapeRoundFact[] = Array.from({ length: 6 }, (_, index) => ({
92
+ analysisVersion: 6, matchId: "m1", mapName: "de_ancient", roundNumber: index + 1, teamKey: "teamA", side: "t",
93
+ openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
94
+ openingWindows: [{ startTick: 100, endTick: 1380, coverageSeconds: 20, componentSizes: [5], partition: "5", componentPlayerIndices: [[0, 1, 2, 3, 4]] }],
95
+ coverageSeconds: 20, windows: [], availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
96
+ }));
97
+ const supported = Array.from({ length: 6 }, (_, index) => row(0, index + 1, { side: "t", utilityUseCount: 1, openingUtilityUseCount: 1 }));
98
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported, teamShapeRounds: shapes })[0]?.modifiers).toContain("utility_supportive");
99
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported.map((item) => ({ ...item, utilityUseCount: 0, openingUtilityUseCount: 0 })), teamShapeRounds: shapes })[0]?.modifiers).not.toContain("utility_supportive");
100
+ expect(buildPlayerMapRoleEvidence({ playerPositionRounds: supported.map((item) => ({ ...item, side: "ct" })), teamShapeRounds: shapes })[0]?.modifiers).toContain("utility_supportive");
101
+ });
102
+ });
@@ -0,0 +1,358 @@
1
+ import {
2
+ MAP_ROLE_EVIDENCE_VERSION,
3
+ playerMapRoleEvidenceSchema,
4
+ teamMapResponsibilityEvidenceSchema,
5
+ type MatchMapIntelligenceFacts,
6
+ type PlayerMapRoleEvidence,
7
+ type PlayerPositionRoundFact,
8
+ type RoleModifier,
9
+ type SupportedMapName,
10
+ type TeamMapResponsibilityEvidence,
11
+ type TeamResponsibility,
12
+ type TeamShapeRoundFact,
13
+ type WeaponDuty,
14
+ } from "@cs2dak/contract";
15
+ import type { PlayerIdentityMap } from "./index.js";
16
+
17
+ export const MAP_ROLE_MODEL_VERSION = "cs2-demo-analysis-kit/map-role-model-5.0";
18
+ export const MAP_ROLE_THRESHOLDS = Object.freeze({
19
+ minimumEligibleRounds: 6,
20
+ reliableEligibleRounds: 12,
21
+ primaryAwpMinimumQualifiedRounds: 12,
22
+ secondaryAwpMinimumQualifiedRounds: 6,
23
+ dominantPositionShare: 0.45,
24
+ responsibilitySeparation: 0.12,
25
+ supportUtilityUsesPerRound: 0.4,
26
+ supportOpeningUtilityUsesPerRound: 0.16,
27
+ });
28
+
29
+ const SUPPORTED_MAPS = new Set<SupportedMapName>([
30
+ "de_ancient", "de_anubis", "de_dust2", "de_inferno", "de_mirage", "de_nuke", "de_overpass",
31
+ ]);
32
+
33
+ export interface MapRoleEvidenceFacts {
34
+ playerPositionRounds: PlayerPositionRoundFact[];
35
+ teamShapeRounds: TeamShapeRoundFact[];
36
+ }
37
+
38
+ export interface MapRoleEvidenceOptions {
39
+ identityMap?: PlayerIdentityMap;
40
+ teamIdentityMap?: Record<string, string>;
41
+ }
42
+
43
+ function flatten(facts: MapRoleEvidenceFacts | MatchMapIntelligenceFacts[]): MapRoleEvidenceFacts {
44
+ return Array.isArray(facts)
45
+ ? { playerPositionRounds: facts.flatMap((fact) => fact.playerPositionRounds), teamShapeRounds: facts.flatMap((fact) => fact.teamShapeRounds) }
46
+ : facts;
47
+ }
48
+
49
+ export function mapRolePlayerKey(steamId64: string, map: PlayerIdentityMap): string {
50
+ const mapped = map[steamId64];
51
+ return mapped == null ? `steam:${steamId64}` : typeof mapped === "string" ? mapped : mapped.playerKey;
52
+ }
53
+
54
+ export function mapRoleTeamKey(row: { matchId: string; teamKey: string }, map: Record<string, string>): string {
55
+ return map[`${row.matchId}:${row.teamKey}`] ?? `${row.matchId}:${row.teamKey}`;
56
+ }
57
+
58
+ function mean(values: Array<number | null>): number | null {
59
+ const usable = values.filter((value): value is number => value != null && Number.isFinite(value));
60
+ return usable.length === 0 ? null : usable.reduce((sum, value) => sum + value, 0) / usable.length;
61
+ }
62
+
63
+ function rounded(value: number | null, digits = 3): number | null {
64
+ return value == null ? null : Number(value.toFixed(digits));
65
+ }
66
+
67
+ function dataQuality(rows: PlayerPositionRoundFact[]): number {
68
+ if (rows.length === 0) return 0;
69
+ const replay = rows.filter((row) => row.availability.replay === "available").length / rows.length;
70
+ const callouts = rows.reduce((sum, row) => sum + (row.calloutCoverage ?? 0), 0) / rows.length;
71
+ const economy = rows.filter((row) => row.economyType != null).length / rows.length;
72
+ return rounded(replay * 0.5 + callouts * 0.35 + economy * 0.15)!;
73
+ }
74
+
75
+ function evidenceStatus(rows: PlayerPositionRoundFact[], eligible: number, quality: number): "ready" | "mixed" | "insufficient" | "unknown" {
76
+ if (rows.length === 0 || rows.every((row) => row.availability.replay === "missing")) return "unknown";
77
+ if (eligible < MAP_ROLE_THRESHOLDS.minimumEligibleRounds) return "insufficient";
78
+ return quality < 0.72 ? "mixed" : "ready";
79
+ }
80
+
81
+ function localAwpDuty(input: { qualified: number; active: number | null; freeze: number; share: number | null; consistency: number | null }): WeaponDuty {
82
+ if (input.active == null) return "rifler";
83
+ const ownership = input.qualified > 0 ? input.freeze / input.qualified : 0;
84
+ if (input.qualified >= MAP_ROLE_THRESHOLDS.primaryAwpMinimumQualifiedRounds && ownership >= 0.4 && (input.share ?? 0) >= 0.55 && (input.consistency ?? 0) >= 0.55) return "primary_awper";
85
+ if (input.qualified >= MAP_ROLE_THRESHOLDS.secondaryAwpMinimumQualifiedRounds && ownership >= 0.18 && (input.share ?? 0) >= 0.2) return "secondary_awper";
86
+ if (input.freeze > 0 || input.active > 0) return "situational_awper";
87
+ return "rifler";
88
+ }
89
+
90
+ function locators(rows: PlayerPositionRoundFact[]): Array<{ matchId: string; roundNumber: number; positionGroupId?: string }> {
91
+ return [...rows]
92
+ .sort((a, b) => (b.openingEligibleSeconds ?? 0) - (a.openingEligibleSeconds ?? 0) || a.matchId.localeCompare(b.matchId) || a.roundNumber - b.roundNumber)
93
+ .slice(0, 5)
94
+ .map((row) => ({ matchId: row.matchId, roundNumber: row.roundNumber, positionGroupId: row.openingPositionGroupDwell[0]?.positionGroupId }));
95
+ }
96
+
97
+ interface ShapeSummary {
98
+ mainShare: number | null;
99
+ noUniqueCoreShare: number | null;
100
+ isolatedShare: number | null;
101
+ formationShares: Record<string, number>;
102
+ }
103
+
104
+ function shapeSummary(rows: PlayerPositionRoundFact[], shapeByRound: Map<string, TeamShapeRoundFact>): ShapeSummary {
105
+ let covered = 0;
106
+ let main = 0;
107
+ let noUniqueCore = 0;
108
+ let isolated = 0;
109
+ const formations = new Map<string, number>();
110
+ for (const row of rows) {
111
+ const shape = shapeByRound.get(`${row.matchId}\t${row.roundNumber}\t${row.teamKey}`);
112
+ if (!shape) continue;
113
+ for (const window of shape.openingWindows) {
114
+ const component = window.componentPlayerIndices.find((members) => members.includes(row.playerIndex));
115
+ if (!component) continue;
116
+ covered += window.coverageSeconds;
117
+ const largest = Math.max(...window.componentSizes);
118
+ const uniqueCore = largest >= 3 && window.componentSizes.filter((size) => size === largest).length === 1 && largest > window.componentSizes.reduce((sum, size) => sum + size, 0) / 2
119
+ ? window.componentPlayerIndices.find((members) => members.length === largest) ?? null
120
+ : null;
121
+ if (uniqueCore?.includes(row.playerIndex)) main += window.coverageSeconds;
122
+ if (uniqueCore == null) noUniqueCore += window.coverageSeconds;
123
+ if (component.length === 1) isolated += window.coverageSeconds;
124
+ formations.set(window.partition, (formations.get(window.partition) ?? 0) + window.coverageSeconds);
125
+ }
126
+ }
127
+ return {
128
+ mainShare: covered > 0 ? rounded(main / covered) : null,
129
+ noUniqueCoreShare: covered > 0 ? rounded(noUniqueCore / covered) : null,
130
+ isolatedShare: covered > 0 ? rounded(isolated / covered) : null,
131
+ formationShares: Object.fromEntries([...formations.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, rounded(value / Math.max(covered, 1))!])),
132
+ };
133
+ }
134
+
135
+ function responsibility(side: "t" | "ct", input: { stability: number | null; relative: number | null; openingMain: number | null; openingIsolated: number | null; delayedConvergenceShare: number | null }): TeamResponsibility {
136
+ const stable = input.stability ?? 0;
137
+ const main = input.openingMain ?? 0;
138
+ const isolated = input.openingIsolated ?? 0;
139
+ const converged = (input.delayedConvergenceShare ?? 0) > 0;
140
+ if (side === "t") {
141
+ if (isolated >= 0.28 && converged) return "late_joining";
142
+ if (isolated >= 0.32 && stable >= 0.45) return "extremity";
143
+ if (main >= 0.68) return "pack";
144
+ if (stable >= 0.45) return "stable_default";
145
+ return "mixed";
146
+ }
147
+ if (stable >= 0.6 && (input.relative ?? 0) >= 0.08 && (isolated >= 0.12 || main >= 0.35)) return "anchor_tendency";
148
+ if (converged && stable < 0.55) return "component_mobile";
149
+ if (isolated >= 0.2) return "independent_mobile";
150
+ if (stable >= 0.55) return "stable_position";
151
+ return "mixed";
152
+ }
153
+
154
+ function modifiers(input: { stability: number | null; openingIsolated: number | null; delayedConvergenceShare: number | null; utilityPerRound: number; openingUtilityPerRound: number }): RoleModifier[] {
155
+ const result: RoleModifier[] = [];
156
+ if (input.utilityPerRound >= MAP_ROLE_THRESHOLDS.supportUtilityUsesPerRound && input.openingUtilityPerRound >= MAP_ROLE_THRESHOLDS.supportOpeningUtilityUsesPerRound) result.push("utility_supportive");
157
+ if ((input.stability ?? 0) >= 0.55) result.push("positionally_stable");
158
+ if ((input.openingIsolated ?? 0) >= 0.2) result.push("spatially_isolated");
159
+ if ((input.delayedConvergenceShare ?? 0) > 0) result.push("component_mobile");
160
+ return result;
161
+ }
162
+
163
+ export function buildPlayerMapRoleEvidence(facts: MapRoleEvidenceFacts | MatchMapIntelligenceFacts[], options: MapRoleEvidenceOptions = {}): PlayerMapRoleEvidence[] {
164
+ const input = flatten(facts);
165
+ const identityMap = options.identityMap ?? {};
166
+ const teamIdentityMap = options.teamIdentityMap ?? {};
167
+ const shapeByRound = new Map(input.teamShapeRounds.map((row) => [`${row.matchId}\t${row.roundNumber}\t${row.teamKey}`, row]));
168
+ const groups = new Map<string, PlayerPositionRoundFact[]>();
169
+ for (const row of input.playerPositionRounds) {
170
+ if (!SUPPORTED_MAPS.has(row.mapName as SupportedMapName)) continue;
171
+ // Keep match granularity here. Presentation owns corpus aggregation, while scoped
172
+ // declarations need an exact match-time boundary rather than a mixed row.
173
+ const key = [mapRolePlayerKey(row.steamId64, identityMap), mapRoleTeamKey(row, teamIdentityMap), row.matchId, row.mapName, row.side].join("\t");
174
+ groups.set(key, [...(groups.get(key) ?? []), row]);
175
+ }
176
+
177
+ const cells = [...groups.entries()].map(([key, rows]) => {
178
+ const [playerKey, teamKey, matchId, mapName, side] = key.split("\t") as [string, string, string, SupportedMapName, "t" | "ct"];
179
+ const eligibleRows = rows.filter((row) => (row.openingEligibleSeconds ?? 0) > 0);
180
+ const eligibleSeconds = eligibleRows.reduce((sum, row) => sum + (row.openingEligibleSeconds ?? 0), 0);
181
+ const quality = dataQuality(rows);
182
+ const dwell = new Map<string, { seconds: number; rounds: Set<string> }>();
183
+ for (const row of eligibleRows) for (const item of row.openingPositionGroupDwell) {
184
+ const cell = dwell.get(item.positionGroupId) ?? { seconds: 0, rounds: new Set<string>() };
185
+ cell.seconds += item.seconds;
186
+ cell.rounds.add(`${row.matchId}:${row.roundNumber}`);
187
+ dwell.set(item.positionGroupId, cell);
188
+ }
189
+ const positionGroups = [...dwell.entries()].map(([positionGroupId, value]) => ({
190
+ positionGroupId, seconds: rounded(value.seconds, 2)!, share: eligibleSeconds > 0 ? rounded(value.seconds / eligibleSeconds)! : 0, roundCount: value.rounds.size,
191
+ })).sort((a, b) => b.seconds - a.seconds || a.positionGroupId.localeCompare(b.positionGroupId));
192
+ const dominant = positionGroups[0] ?? null;
193
+ const isolationSeconds = eligibleRows.reduce((sum, row) => sum + row.isolationSegments.reduce((inner, segment) => inner + segment.seconds, 0), 0);
194
+ const fullSeconds = eligibleRows.reduce((sum, row) => sum + (row.eligibleSeconds ?? 0), 0);
195
+ const shape = shapeSummary(eligibleRows, shapeByRound);
196
+ const qualified = eligibleRows.filter((row) => row.economyType === "full");
197
+ const active = qualified.some((row) => row.activeAwpSeconds != null) ? qualified.reduce((sum, row) => sum + (row.activeAwpSeconds ?? 0), 0) : null;
198
+ const shots = qualified.some((row) => row.awpShots != null) ? qualified.reduce((sum, row) => sum + (row.awpShots ?? 0), 0) : null;
199
+ const kills = qualified.some((row) => row.awpKills != null) ? qualified.reduce((sum, row) => sum + (row.awpKills ?? 0), 0) : null;
200
+ const byMatch = new Map<string, PlayerPositionRoundFact[]>();
201
+ for (const row of qualified) byMatch.set(row.matchId, [...(byMatch.get(row.matchId) ?? []), row]);
202
+ const matchConsistency = byMatch.size === 0 ? null : rounded([...byMatch.values()].filter((matchRows) => matchRows.some((row) => row.freezeAwpOwnership || (row.activeAwpSeconds ?? 0) >= 3)).length / byMatch.size);
203
+ const spatial = {
204
+ dominantGroupStability: dominant == null ? null : rounded(dominant.roundCount / Math.max(eligibleRows.length, 1)),
205
+ teamRelativeGroupShare: null as number | null,
206
+ isolationSeconds: fullSeconds > 0 ? rounded(isolationSeconds, 2) : null,
207
+ isolationShare: fullSeconds > 0 ? rounded(isolationSeconds / fullSeconds) : null,
208
+ rejoinCount: fullSeconds > 0 ? eligibleRows.reduce((sum, row) => sum + row.rejoinTicks.length, 0) : null,
209
+ delayedConvergenceRoundShare: fullSeconds > 0 ? rounded(eligibleRows.filter((row) => row.delayedConvergences.length > 0).length / Math.max(eligibleRows.length, 1)) : null,
210
+ movementSync: rounded(mean(eligibleRows.map((row) => row.movementSync))),
211
+ openingMainComponentShare: shape.mainShare,
212
+ openingNoUniqueCoreShare: shape.noUniqueCoreShare,
213
+ openingIsolatedShare: shape.isolatedShare,
214
+ formationShares: shape.formationShares,
215
+ };
216
+ const utilityUses = eligibleRows.reduce((sum, row) => sum + row.utilityUseCount, 0);
217
+ const openingUtilityUses = eligibleRows.reduce((sum, row) => sum + row.openingUtilityUseCount, 0);
218
+ const support = {
219
+ utilityUses,
220
+ openingUtilityUses,
221
+ utilityUsePerRound: rounded(utilityUses / Math.max(eligibleRows.length, 1))!,
222
+ openingUtilityUsePerRound: rounded(openingUtilityUses / Math.max(eligibleRows.length, 1))!,
223
+ };
224
+ return {
225
+ version: MAP_ROLE_EVIDENCE_VERSION, playerKey, teamKey, mapName, side,
226
+ status: evidenceStatus(rows, eligibleRows.length, quality),
227
+ confidence: rounded(Math.min(1, eligibleRows.length / MAP_ROLE_THRESHOLDS.reliableEligibleRounds) * quality)!,
228
+ sample: { observedRounds: rows.length, eligibleRounds: eligibleRows.length, eligibleSeconds: rounded(eligibleSeconds, 2)!, matchCount: new Set(rows.map((row) => row.matchId)).size, dataQuality: quality, coverage: rounded(mean(rows.map((row) => row.calloutCoverage))) },
229
+ matchIds: [matchId],
230
+ positionGroups, spatial, support,
231
+ responsibility: responsibility(side, { stability: spatial.dominantGroupStability, relative: spatial.teamRelativeGroupShare, openingMain: shape.mainShare, openingIsolated: shape.isolatedShare, delayedConvergenceShare: spatial.delayedConvergenceRoundShare }),
232
+ modifiers: modifiers({ stability: spatial.dominantGroupStability, openingIsolated: shape.isolatedShare, delayedConvergenceShare: spatial.delayedConvergenceRoundShare, utilityPerRound: support.utilityUsePerRound, openingUtilityPerRound: support.openingUtilityUsePerRound }),
233
+ awp: { duty: "rifler" as WeaponDuty, eligibleRounds: eligibleRows.length, qualifiedLongGunRounds: qualified.length, freezeOwnershipRounds: qualified.filter((row) => row.freezeAwpOwnership === true).length, activeSeconds: rounded(active, 2), shots, kills, teamActiveShare: null as number | null, usageConcentration: null as number | null, matchConsistency },
234
+ representativeRounds: locators(rows),
235
+ basis: [`${MAP_ROLE_MODEL_VERSION}:opening position/component 用于默认职责,full-round movement 用于轮转、分离和回归解释。`],
236
+ limitations: ["主结构职责与 utility / stability / isolation modifiers 分开;标签只描述可观察空间行为。"],
237
+ };
238
+ });
239
+
240
+ for (const cell of cells) {
241
+ const peers = cells.filter((peer) => peer.teamKey === cell.teamKey && peer.mapName === cell.mapName && peer.side === cell.side && peer.matchIds[0] === cell.matchIds[0]);
242
+ const group = cell.positionGroups[0]?.positionGroupId;
243
+ if (group) {
244
+ const values = peers.filter((peer) => peer.playerKey !== cell.playerKey).map((peer) => peer.positionGroups.find((item) => item.positionGroupId === group)?.share ?? 0);
245
+ cell.spatial.teamRelativeGroupShare = values.length ? rounded(cell.positionGroups[0]!.share - values.reduce((sum, value) => sum + value, 0) / values.length) : null;
246
+ }
247
+ const total = peers.reduce((sum, peer) => sum + (peer.awp.activeSeconds ?? 0), 0);
248
+ cell.awp.teamActiveShare = cell.awp.activeSeconds == null || total === 0 ? null : rounded(cell.awp.activeSeconds / total);
249
+ cell.awp.usageConcentration = total === 0 ? null : rounded(Math.max(...peers.map((peer) => peer.awp.activeSeconds ?? 0)) / total);
250
+ cell.awp.duty = localAwpDuty({ qualified: cell.awp.qualifiedLongGunRounds, active: cell.awp.activeSeconds, freeze: cell.awp.freezeOwnershipRounds, share: cell.awp.teamActiveShare, consistency: cell.awp.matchConsistency });
251
+ cell.responsibility = responsibility(cell.side, { stability: cell.spatial.dominantGroupStability, relative: cell.spatial.teamRelativeGroupShare, openingMain: cell.spatial.openingMainComponentShare, openingIsolated: cell.spatial.openingIsolatedShare, delayedConvergenceShare: cell.spatial.delayedConvergenceRoundShare });
252
+ cell.modifiers = modifiers({ stability: cell.spatial.dominantGroupStability, openingIsolated: cell.spatial.openingIsolatedShare, delayedConvergenceShare: cell.spatial.delayedConvergenceRoundShare, utilityPerRound: cell.support.utilityUsePerRound, openingUtilityPerRound: cell.support.openingUtilityUsePerRound });
253
+ }
254
+ return cells.map((cell) => playerMapRoleEvidenceSchema.parse(cell));
255
+ }
256
+
257
+ export function buildTeamMapResponsibilityEvidence(facts: MapRoleEvidenceFacts | MatchMapIntelligenceFacts[], options: MapRoleEvidenceOptions = {}): TeamMapResponsibilityEvidence[] {
258
+ const matchPlayers = buildPlayerMapRoleEvidence(facts, options);
259
+ const groups = new Map<string, PlayerMapRoleEvidence[]>();
260
+ for (const player of matchPlayers) groups.set([player.teamKey, player.mapName, player.side].join("\t"), [...(groups.get([player.teamKey, player.mapName, player.side].join("\t")) ?? []), player]);
261
+ return [...groups.entries()].map(([key, matchRows]) => {
262
+ const [teamKey, mapName, side] = key.split("\t") as [string, SupportedMapName, "t" | "ct"];
263
+ const byPlayer = new Map<string, PlayerMapRoleEvidence[]>();
264
+ for (const row of matchRows) byPlayer.set(row.playerKey, [...(byPlayer.get(row.playerKey) ?? []), row]);
265
+ const rows = [...byPlayer.entries()].map(([playerKey, playerRows]) => {
266
+ const eligibleRounds = playerRows.reduce((sum, row) => sum + row.sample.eligibleRounds, 0);
267
+ const eligibleSeconds = playerRows.reduce((sum, row) => sum + row.sample.eligibleSeconds, 0);
268
+ const weight = (pick: (row: PlayerMapRoleEvidence) => number | null, by: "rounds" | "seconds" = "seconds") => {
269
+ const usable = playerRows.filter((row) => pick(row) != null);
270
+ const denominator = usable.reduce((sum, row) => sum + (by === "rounds" ? row.sample.eligibleRounds : row.sample.eligibleSeconds), 0);
271
+ return denominator === 0 ? null : rounded(usable.reduce((sum, row) => sum + pick(row)! * (by === "rounds" ? row.sample.eligibleRounds : row.sample.eligibleSeconds), 0) / denominator);
272
+ };
273
+ const dwell = new Map<string, { seconds: number; rounds: number }>();
274
+ for (const row of playerRows) for (const group of row.positionGroups) {
275
+ const current = dwell.get(group.positionGroupId) ?? { seconds: 0, rounds: 0 };
276
+ current.seconds += group.seconds;
277
+ current.rounds += group.roundCount;
278
+ dwell.set(group.positionGroupId, current);
279
+ }
280
+ const positionGroups = [...dwell.entries()].map(([positionGroupId, value]) => ({
281
+ positionGroupId, seconds: rounded(value.seconds, 2)!, share: eligibleSeconds > 0 ? rounded(value.seconds / eligibleSeconds)! : 0, roundCount: value.rounds,
282
+ })).sort((a, b) => b.seconds - a.seconds || a.positionGroupId.localeCompare(b.positionGroupId));
283
+ const formations = new Set(playerRows.flatMap((row) => Object.keys(row.spatial.formationShares)));
284
+ const utilityUses = playerRows.reduce((sum, row) => sum + row.support.utilityUses, 0);
285
+ const openingUtilityUses = playerRows.reduce((sum, row) => sum + row.support.openingUtilityUses, 0);
286
+ const quality = weight((row) => row.sample.dataQuality) ?? 0;
287
+ const spatial = {
288
+ dominantGroupStability: positionGroups[0] == null ? null : rounded(positionGroups[0].roundCount / Math.max(eligibleRounds, 1)),
289
+ teamRelativeGroupShare: null as number | null,
290
+ isolationSeconds: playerRows.some((row) => row.spatial.isolationSeconds != null) ? rounded(playerRows.reduce((sum, row) => sum + (row.spatial.isolationSeconds ?? 0), 0), 2) : null,
291
+ isolationShare: weight((row) => row.spatial.isolationShare),
292
+ rejoinCount: playerRows.some((row) => row.spatial.rejoinCount != null) ? playerRows.reduce((sum, row) => sum + (row.spatial.rejoinCount ?? 0), 0) : null,
293
+ delayedConvergenceRoundShare: weight((row) => row.spatial.delayedConvergenceRoundShare, "rounds"),
294
+ movementSync: weight((row) => row.spatial.movementSync),
295
+ openingMainComponentShare: weight((row) => row.spatial.openingMainComponentShare),
296
+ openingNoUniqueCoreShare: weight((row) => row.spatial.openingNoUniqueCoreShare),
297
+ openingIsolatedShare: weight((row) => row.spatial.openingIsolatedShare),
298
+ formationShares: Object.fromEntries([...formations].sort().map((formation) => [formation, weight((row) => row.spatial.formationShares[formation] ?? 0) ?? 0])),
299
+ };
300
+ const support = { utilityUses, openingUtilityUses, utilityUsePerRound: rounded(utilityUses / Math.max(eligibleRounds, 1))!, openingUtilityUsePerRound: rounded(openingUtilityUses / Math.max(eligibleRounds, 1))! };
301
+ const active = playerRows.some((row) => row.awp.activeSeconds != null) ? rounded(playerRows.reduce((sum, row) => sum + (row.awp.activeSeconds ?? 0), 0), 2) : null;
302
+ const awp = {
303
+ duty: "rifler" as WeaponDuty,
304
+ eligibleRounds,
305
+ qualifiedLongGunRounds: playerRows.reduce((sum, row) => sum + row.awp.qualifiedLongGunRounds, 0),
306
+ freezeOwnershipRounds: playerRows.reduce((sum, row) => sum + row.awp.freezeOwnershipRounds, 0),
307
+ activeSeconds: active,
308
+ shots: playerRows.some((row) => row.awp.shots != null) ? playerRows.reduce((sum, row) => sum + (row.awp.shots ?? 0), 0) : null,
309
+ kills: playerRows.some((row) => row.awp.kills != null) ? playerRows.reduce((sum, row) => sum + (row.awp.kills ?? 0), 0) : null,
310
+ teamActiveShare: null as number | null,
311
+ usageConcentration: null as number | null,
312
+ matchConsistency: playerRows.length === 0 ? null : rounded(playerRows.filter((row) => row.awp.freezeOwnershipRounds > 0 || (row.awp.activeSeconds ?? 0) >= 3).length / playerRows.length),
313
+ };
314
+ const status = playerRows.every((row) => row.status === "unknown") ? "unknown" : eligibleRounds < MAP_ROLE_THRESHOLDS.minimumEligibleRounds ? "insufficient" : quality < 0.72 ? "mixed" : "ready";
315
+ return {
316
+ version: MAP_ROLE_EVIDENCE_VERSION, playerKey, teamKey, mapName, side, status,
317
+ confidence: rounded(Math.min(1, eligibleRounds / MAP_ROLE_THRESHOLDS.reliableEligibleRounds) * quality)!,
318
+ sample: { observedRounds: playerRows.reduce((sum, row) => sum + row.sample.observedRounds, 0), eligibleRounds, eligibleSeconds: rounded(eligibleSeconds, 2)!, matchCount: new Set(playerRows.flatMap((row) => row.matchIds)).size, dataQuality: quality, coverage: weight((row) => row.sample.coverage) },
319
+ matchIds: [...new Set(playerRows.flatMap((row) => row.matchIds))].sort(), positionGroups, spatial, support,
320
+ responsibility: responsibility(side, { stability: spatial.dominantGroupStability, relative: null, openingMain: spatial.openingMainComponentShare, openingIsolated: spatial.openingIsolatedShare, delayedConvergenceShare: spatial.delayedConvergenceRoundShare }),
321
+ modifiers: modifiers({ stability: spatial.dominantGroupStability, openingIsolated: spatial.openingIsolatedShare, delayedConvergenceShare: spatial.delayedConvergenceRoundShare, utilityPerRound: support.utilityUsePerRound, openingUtilityPerRound: support.openingUtilityUsePerRound }),
322
+ awp,
323
+ representativeRounds: playerRows.flatMap((row) => row.representativeRounds).sort((a, b) => a.matchId.localeCompare(b.matchId) || a.roundNumber - b.roundNumber).slice(0, 5),
324
+ basis: [`${MAP_ROLE_MODEL_VERSION}:team matrix 先聚合为唯一 player × team × map × side cell。`],
325
+ limitations: ["位置重叠是描述性 concentration,不自动解释为职责冲突。"],
326
+ } satisfies PlayerMapRoleEvidence;
327
+ });
328
+ for (const player of rows) {
329
+ const peers = rows.filter((peer) => peer.playerKey !== player.playerKey);
330
+ const group = player.positionGroups[0]?.positionGroupId;
331
+ if (group && peers.length) player.spatial.teamRelativeGroupShare = rounded(player.positionGroups[0]!.share - peers.reduce((sum, peer) => sum + (peer.positionGroups.find((item) => item.positionGroupId === group)?.share ?? 0), 0) / peers.length);
332
+ const total = rows.reduce((sum, peer) => sum + (peer.awp.activeSeconds ?? 0), 0);
333
+ player.awp.teamActiveShare = player.awp.activeSeconds == null || total === 0 ? null : rounded(player.awp.activeSeconds / total);
334
+ player.awp.usageConcentration = total === 0 ? null : rounded(Math.max(...rows.map((peer) => peer.awp.activeSeconds ?? 0)) / total);
335
+ player.awp.duty = localAwpDuty({ qualified: player.awp.qualifiedLongGunRounds, active: player.awp.activeSeconds, freeze: player.awp.freezeOwnershipRounds, share: player.awp.teamActiveShare, consistency: player.awp.matchConsistency });
336
+ player.responsibility = responsibility(side, { stability: player.spatial.dominantGroupStability, relative: player.spatial.teamRelativeGroupShare, openingMain: player.spatial.openingMainComponentShare, openingIsolated: player.spatial.openingIsolatedShare, delayedConvergenceShare: player.spatial.delayedConvergenceRoundShare });
337
+ player.modifiers = modifiers({ stability: player.spatial.dominantGroupStability, openingIsolated: player.spatial.openingIsolatedShare, delayedConvergenceShare: player.spatial.delayedConvergenceRoundShare, utilityPerRound: player.support.utilityUsePerRound, openingUtilityPerRound: player.support.openingUtilityUsePerRound });
338
+ }
339
+ const overlap = new Map<string, string[]>();
340
+ for (const player of rows) {
341
+ const primary = player.positionGroups[0];
342
+ if (primary && primary.share >= 0.35) overlap.set(primary.positionGroupId, [...(overlap.get(primary.positionGroupId) ?? []), player.playerKey]);
343
+ }
344
+ const positionOverlap = [...overlap.entries()].filter(([, keys]) => keys.length >= 2).map(([positionGroupId, playerKeys]) => ({ positionGroupId, playerKeys: [...new Set(playerKeys)].sort(), share: rounded(new Set(playerKeys).size / Math.max(rows.length, 1))! })).sort((a, b) => a.positionGroupId.localeCompare(b.positionGroupId));
345
+ const positionConcentration = overlap.size === 0 ? null : rounded(Math.max(...[...overlap.values()].map((keys) => new Set(keys).size)) / Math.max(rows.length, 1));
346
+ const usable = rows.filter((row) => row.status === "ready" || row.status === "mixed");
347
+ const unstableCoverage = usable.length > 0 && (usable.some((row) => (row.spatial.dominantGroupStability ?? 1) < 0.45) || usable.filter((row) => row.status === "mixed").length / usable.length >= 0.5);
348
+ const status = usable.length === 0 ? rows.some((row) => row.status === "unknown") ? "unknown" : "insufficient" : usable.length < 2 ? "insufficient" : unstableCoverage ? "mixed" : "ready";
349
+ return teamMapResponsibilityEvidenceSchema.parse({
350
+ version: MAP_ROLE_EVIDENCE_VERSION, teamKey, mapName, side, status,
351
+ confidence: rounded(rows.reduce((sum, row) => sum + row.confidence * row.sample.eligibleSeconds, 0) / Math.max(rows.reduce((sum, row) => sum + row.sample.eligibleSeconds, 0), 1))!,
352
+ players: rows.map((row) => playerMapRoleEvidenceSchema.parse(row)).sort((a, b) => a.playerKey.localeCompare(b.playerKey)), positionOverlap, positionConcentration, unstableCoverage,
353
+ representativeRounds: rows.flatMap((row) => row.representativeRounds).slice(0, 5),
354
+ basis: ["逐回合 opening component membership、position group 与 full-round continuity 共同形成描述性职责。"],
355
+ limitations: ["不强制五个位置或职责覆盖;position overlap / concentration 只描述多人常驻同组。"],
356
+ });
357
+ }).sort((a, b) => a.teamKey.localeCompare(b.teamKey) || a.mapName.localeCompare(b.mapName) || a.side.localeCompare(b.side));
358
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage } from "@cs2dak/contract";
3
+ import { buildOpeningPatternClusters } from "./patterns.js";
4
+
5
+ function pkg(overrides?: Partial<DemoPackage>): DemoPackage {
6
+ return {
7
+ manifest: {
8
+ schemaVersion: "cs2-demo-format/3.0",
9
+ exporter: { name: "test", version: "0" },
10
+ parser: { name: "test", version: "0" },
11
+ demo: { hash: null, sourceFileName: null },
12
+ mapName: "de_mirage",
13
+ tickrate: 64,
14
+ exportedAt: "2026-01-01T00:00:00Z",
15
+ files: {
16
+ match: "match.json",
17
+ players: "players.json",
18
+ rounds: "rounds.json",
19
+ playerStats: "player-stats.json",
20
+ playerEconomies: "player-economies.json",
21
+ kills: "kills.json",
22
+ damages: "damages.json",
23
+ blinds: "blinds.json",
24
+ bombs: "bombs.json",
25
+ grenades: "grenades.json",
26
+ clutches: "clutches.json"
27
+ }
28
+ },
29
+ match: {
30
+ mapName: "de_mirage",
31
+ tickrate: 64,
32
+ durationSeconds: 120,
33
+ serverName: null,
34
+ source: "test",
35
+ teamA: { teamKey: "teamA", name: "A", score: 1 },
36
+ teamB: { teamKey: "teamB", name: "B", score: 0 }
37
+ },
38
+ players: [
39
+ { steamId64: "76561198000000001", name: "A", teamKey: "teamA" },
40
+ { steamId64: "76561198000000002", name: "B", teamKey: "teamB" }
41
+ ],
42
+ rounds: [{
43
+ roundNumber: 1,
44
+ startTick: 1,
45
+ freezeEndTick: 64,
46
+ endTick: 4096,
47
+ teamASide: "t",
48
+ teamBSide: "ct",
49
+ teamAScoreBefore: 0,
50
+ teamBScoreBefore: 0,
51
+ teamAEconomy: "full",
52
+ teamBEconomy: "full",
53
+ winnerTeamKey: "teamA",
54
+ winnerSide: "t",
55
+ endReason: "t_win"
56
+ }],
57
+ playerEconomies: [],
58
+ playerStats: [],
59
+ kills: [],
60
+ damages: [],
61
+ blinds: [],
62
+ bombs: [],
63
+ grenades: [],
64
+ clutches: [],
65
+ ...overrides
66
+ } as DemoPackage;
67
+ }
68
+
69
+ describe("buildOpeningPatternClusters", () => {
70
+ it("clusters rounds by replay callout distribution and grenade sequence", () => {
71
+ const places = Array.from({ length: 16 }, () => 0);
72
+ const demo = pkg({
73
+ replay: {
74
+ meta: { tickrate: 64, sampleRate: 1, coordScale: 1, angleScale: 10 },
75
+ placeDict: ["TSpawn", "LongDoors"],
76
+ weaponDict: [],
77
+ rounds: [{
78
+ roundNumber: 1,
79
+ startTick: 64,
80
+ tickStep: 64,
81
+ frameCount: 16,
82
+ projectiles: [],
83
+ players: [
84
+ {
85
+ playerIndex: 0,
86
+ x: [0], y: [0], z: [0], yaw: [0], pitch: [0],
87
+ hp: Array.from({ length: 16 }, () => 100),
88
+ armor: Array.from({ length: 16 }, () => 100),
89
+ money: Array.from({ length: 16 }, () => 800),
90
+ equipValue: Array.from({ length: 16 }, () => 800),
91
+ place: places,
92
+ flash: Array.from({ length: 16 }, () => 0),
93
+ flags: Array.from({ length: 16 }, () => 1),
94
+ weapon: Array.from({ length: 16 }, () => -1),
95
+ grenades: Array.from({ length: 16 }, () => [])
96
+ },
97
+ {
98
+ playerIndex: 1,
99
+ x: [0], y: [0], z: [0], yaw: [0], pitch: [0],
100
+ hp: Array.from({ length: 16 }, () => 100),
101
+ armor: Array.from({ length: 16 }, () => 100),
102
+ money: Array.from({ length: 16 }, () => 800),
103
+ equipValue: Array.from({ length: 16 }, () => 800),
104
+ place: Array.from({ length: 16 }, () => 1),
105
+ flash: Array.from({ length: 16 }, () => 0),
106
+ flags: Array.from({ length: 16 }, () => 1),
107
+ weapon: Array.from({ length: 16 }, () => -1),
108
+ grenades: Array.from({ length: 16 }, () => [])
109
+ }
110
+ ]
111
+ }]
112
+ },
113
+ grenades: [
114
+ {
115
+ roundNumber: 1, grenadeId: null,
116
+ throwTick: 70, effectTick: 80, destroyTick: null,
117
+ grenade: "smoke",
118
+ throwerIndex: 0, throwPosition: { x: 0, y: 0, z: 0 },
119
+ effectPosition: { x: 0, y: 0, z: 0 }
120
+ }
121
+ ]
122
+ });
123
+ const clusters = buildOpeningPatternClusters([{ matchId: "m1", pkg: demo }], { windowSeconds: 15 });
124
+ expect(clusters).toHaveLength(2);
125
+ const tCluster = clusters.find((cluster) => cluster.side === "t");
126
+ expect(tCluster?.basis).toBe("TSpawn:1");
127
+ expect(tCluster?.grenadeSequence).toEqual(["smoke"]);
128
+ expect(tCluster?.roundCount).toBe(1);
129
+ });
130
+
131
+ it("returns empty when no grenade or position data defines the opening", () => {
132
+ const demo = pkg();
133
+ const clusters = buildOpeningPatternClusters([{ matchId: "m1", pkg: demo }]);
134
+ expect(clusters).toEqual([]);
135
+ });
136
+ });