@cs2dak/cohort 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 +5 -5
- package/src/index.test.ts +150 -0
- package/src/index.ts +167 -82
- package/src/map-role-evidence.test.ts +102 -0
- package/src/map-role-evidence.ts +358 -0
- package/src/patterns.test.ts +136 -0
- package/src/patterns.ts +117 -0
- package/src/t-role-research-projection.test.ts +144 -0
- package/src/t-role-research-projection.ts +241 -0
- package/src/tactical-clusters.ts +223 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PlayerPositionRoundFact, TResponsibilityResearchFeatures, TeamShapeRoundFact } from "@cs2dak/contract";
|
|
3
|
+
import { buildPlayerMapRoleEvidence } from "./map-role-evidence.js";
|
|
4
|
+
import { buildTResponsibilityResearchProjections, scoreFrozenTResponsibilityFeatures } from "./t-role-research-projection.js";
|
|
5
|
+
|
|
6
|
+
const LUCHOV_FEATURES: TResponsibilityResearchFeatures = {
|
|
7
|
+
dominantGroupStability: 0.8125875,
|
|
8
|
+
teamRelativeGroupShare: 0.1741208333,
|
|
9
|
+
openingIsolatedShare: 0.275425,
|
|
10
|
+
isolationShare: 0.3739791667,
|
|
11
|
+
delayedConvergenceShare: 0.1209125,
|
|
12
|
+
movementSync: 0.3243583333,
|
|
13
|
+
positionTopShare: 0.4567125,
|
|
14
|
+
openingLargestShare: 0.7837100236,
|
|
15
|
+
fullLargestShare: 0.784060982,
|
|
16
|
+
meanTeamCentroidDistance: 683.9742873697,
|
|
17
|
+
openingPathDisplacement: 1926.1863987284,
|
|
18
|
+
openingPathTransitions: 0.3837471185,
|
|
19
|
+
openingPositionEntropy: 0.8382271237,
|
|
20
|
+
fullPositionEntropy: 0.8975500986,
|
|
21
|
+
rejoinsPerMinute: 5.2426204323,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function row(playerIndex: number, roundNumber: number, overrides: Partial<PlayerPositionRoundFact> = {}): PlayerPositionRoundFact {
|
|
25
|
+
return {
|
|
26
|
+
analysisVersion: 6,
|
|
27
|
+
matchId: "m1",
|
|
28
|
+
mapName: "de_ancient",
|
|
29
|
+
roundNumber,
|
|
30
|
+
teamKey: "teamA",
|
|
31
|
+
side: "t",
|
|
32
|
+
playerIndex,
|
|
33
|
+
steamId64: `7656119800000000${playerIndex + 1}`,
|
|
34
|
+
economyType: "full",
|
|
35
|
+
openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
|
|
36
|
+
openingEligibleSeconds: 20,
|
|
37
|
+
openingPositionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_default" : "mid_default", seconds: 20, share: 1 }],
|
|
38
|
+
openingMeanComponentSize: 2,
|
|
39
|
+
openingIsolationSeconds: 0,
|
|
40
|
+
openingUtilityUseCount: 0,
|
|
41
|
+
openingPath: [
|
|
42
|
+
{ tick: 100, callout: "Start", positionGroupId: "spawn", x: 0, y: 0, z: 0 },
|
|
43
|
+
{ tick: 1380, callout: "A", positionGroupId: "a_default", x: 1000, y: 0, z: 0 },
|
|
44
|
+
],
|
|
45
|
+
eligibleSeconds: 40,
|
|
46
|
+
positionGroupDwell: [{ positionGroupId: playerIndex === 0 ? "a_default" : "mid_default", seconds: 40, share: 1 }],
|
|
47
|
+
unresolvedCalloutSeconds: 0,
|
|
48
|
+
calloutCoverage: 1,
|
|
49
|
+
meanNearestTeammateDistance: 250,
|
|
50
|
+
meanTeamCentroidDistance: 500,
|
|
51
|
+
meanComponentSize: 2,
|
|
52
|
+
isolationSegments: [],
|
|
53
|
+
rejoinTicks: [],
|
|
54
|
+
delayedConvergences: [],
|
|
55
|
+
movementSync: 0.3,
|
|
56
|
+
utilityUseCount: 0,
|
|
57
|
+
freezeAwpOwnership: false,
|
|
58
|
+
activeAwpSeconds: 0,
|
|
59
|
+
awpShots: 0,
|
|
60
|
+
awpKills: 0,
|
|
61
|
+
availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shape(roundNumber: number): TeamShapeRoundFact {
|
|
67
|
+
const window = {
|
|
68
|
+
startTick: 100,
|
|
69
|
+
endTick: 1380,
|
|
70
|
+
coverageSeconds: 20,
|
|
71
|
+
componentSizes: [2, 2, 1],
|
|
72
|
+
partition: "2+2+1",
|
|
73
|
+
componentPlayerIndices: [[0, 1], [2, 3], [4]],
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
analysisVersion: 6,
|
|
77
|
+
matchId: "m1",
|
|
78
|
+
mapName: "de_ancient",
|
|
79
|
+
roundNumber,
|
|
80
|
+
teamKey: "teamA",
|
|
81
|
+
side: "t",
|
|
82
|
+
openingWindow: { version: 1, startTick: 100, endTick: 1380, configuredSeconds: 20 },
|
|
83
|
+
openingWindows: [window],
|
|
84
|
+
coverageSeconds: 20,
|
|
85
|
+
windows: [window],
|
|
86
|
+
availability: { replay: "available", nav: "available", callouts: "available", shots: "available" },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("T responsibility research projection", () => {
|
|
91
|
+
it("reproduces the frozen full-fit score for a committed feature vector", () => {
|
|
92
|
+
expect(scoreFrozenTResponsibilityFeatures(LUCHOV_FEATURES)).toEqual({
|
|
93
|
+
packScore: 0.750651,
|
|
94
|
+
lurkerScore: 0.249349,
|
|
95
|
+
confidence: 0.750651,
|
|
96
|
+
candidate: "pack",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("credits tied two-player action units without redefining them as a unique core", () => {
|
|
101
|
+
const rows = Array.from({ length: 12 }, (_, index) => [row(0, index + 1), row(1, index + 1)]).flat();
|
|
102
|
+
const shapes = Array.from({ length: 12 }, (_, index) => shape(index + 1));
|
|
103
|
+
const oldEvidence = buildPlayerMapRoleEvidence({ playerPositionRounds: rows, teamShapeRounds: shapes });
|
|
104
|
+
expect(oldEvidence[0]?.spatial.openingMainComponentShare).toBe(0);
|
|
105
|
+
|
|
106
|
+
const projection = buildTResponsibilityResearchProjections({ playerPositionRounds: rows, teamShapeRounds: shapes });
|
|
107
|
+
expect(projection).toHaveLength(2);
|
|
108
|
+
expect(projection[0]?.features).toMatchObject({ openingLargestShare: 1, fullLargestShare: 1 });
|
|
109
|
+
expect(projection[0]?.candidate).not.toBeNull();
|
|
110
|
+
expect(projection[0]?.basis[0]).toContain("正式 responsibility 保持不变");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("preserves research feature precision until final score serialization", () => {
|
|
114
|
+
const rows = Array.from({ length: 12 }, (_, index) => row(0, index + 1, {
|
|
115
|
+
meanTeamCentroidDistance: index % 2 === 0 ? 500.1234567891234 : 500.9876543219876,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
const projection = buildTResponsibilityResearchProjections({ playerPositionRounds: rows, teamShapeRounds: [] })[0]!;
|
|
119
|
+
expect(projection.features.meanTeamCentroidDistance).toBeCloseTo(500.5555555555555, 11);
|
|
120
|
+
expect(projection.features.meanTeamCentroidDistance).not.toBe(500.555556);
|
|
121
|
+
expect(projection.packScore?.toString().split(".")[1]?.length ?? 0).toBeLessThanOrEqual(6);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps missing replay and unobserved features unknown instead of imputing a role", () => {
|
|
125
|
+
const missing = row(0, 1, {
|
|
126
|
+
openingWindow: null,
|
|
127
|
+
openingEligibleSeconds: null,
|
|
128
|
+
openingPositionGroupDwell: [],
|
|
129
|
+
openingPath: [],
|
|
130
|
+
eligibleSeconds: null,
|
|
131
|
+
positionGroupDwell: [],
|
|
132
|
+
calloutCoverage: null,
|
|
133
|
+
meanTeamCentroidDistance: null,
|
|
134
|
+
movementSync: null,
|
|
135
|
+
availability: { replay: "missing", nav: "missing", callouts: "missing", shots: "missing" },
|
|
136
|
+
});
|
|
137
|
+
expect(buildTResponsibilityResearchProjections({ playerPositionRounds: [missing], teamShapeRounds: [] })[0]).toMatchObject({
|
|
138
|
+
status: "unknown",
|
|
139
|
+
candidate: null,
|
|
140
|
+
packScore: null,
|
|
141
|
+
lurkerScore: null,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
T_RESPONSIBILITY_RESEARCH_PROJECTION_VERSION,
|
|
3
|
+
tResponsibilityResearchProjectionSchema,
|
|
4
|
+
type MatchMapIntelligenceFacts,
|
|
5
|
+
type PlayerMapRoleEvidence,
|
|
6
|
+
type PlayerPositionRoundFact,
|
|
7
|
+
type TResponsibilityResearchFeatures,
|
|
8
|
+
type TResponsibilityResearchProjection,
|
|
9
|
+
type TeamShapeRoundFact,
|
|
10
|
+
} from "@cs2dak/contract";
|
|
11
|
+
import {
|
|
12
|
+
MAP_ROLE_THRESHOLDS,
|
|
13
|
+
buildPlayerMapRoleEvidence,
|
|
14
|
+
mapRolePlayerKey,
|
|
15
|
+
mapRoleTeamKey,
|
|
16
|
+
type MapRoleEvidenceFacts,
|
|
17
|
+
type MapRoleEvidenceOptions,
|
|
18
|
+
} from "./map-role-evidence.js";
|
|
19
|
+
|
|
20
|
+
export const T_RESPONSIBILITY_RESEARCH_MODEL_ID = "cologne-major-2026/parsimonious-v1-full-fit" as const;
|
|
21
|
+
export const T_RESPONSIBILITY_RESEARCH_THRESHOLD = 0.6;
|
|
22
|
+
|
|
23
|
+
type FeatureKey = keyof TResponsibilityResearchFeatures;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Full-fit parameters frozen from the 159-identity Cologne research population.
|
|
27
|
+
* This same-event diagnostic is intentionally not used by the production role selector.
|
|
28
|
+
*/
|
|
29
|
+
const FROZEN_MODEL: ReadonlyArray<readonly [FeatureKey, number, number, number, number]> = [
|
|
30
|
+
["dominantGroupStability", 0.8125875, 0.8110319885369233, 0.06571674270323856, 0.3137454154793391],
|
|
31
|
+
["teamRelativeGroupShare", 0.1838809523809523, 0.1917499578233378, 0.08708704820909378, -0.8074881283271113],
|
|
32
|
+
["openingIsolatedShare", 0.3238657718120805, 0.32872615302182945, 0.07824780489800287, -0.4081235105208964],
|
|
33
|
+
["isolationShare", 0.4547809523809524, 0.46189704535089304, 0.07746145337427911, -0.20892974673701006],
|
|
34
|
+
["delayedConvergenceShare", 0.1265747126436781, 0.12579053416940641, 0.04034628766284785, 0.15699043839244245],
|
|
35
|
+
["movementSync", 0.2942472527472527, 0.2927666973414002, 0.03747820111822078, 0.08503051685027664],
|
|
36
|
+
["positionTopShare", 0.4328125, 0.43450227509018796, 0.056193812672360524, 0.006865937081921921],
|
|
37
|
+
["openingLargestShare", 0.7196198234894773, 0.7102014308840938, 0.08058945177379023, 0.3572344571519129],
|
|
38
|
+
["fullLargestShare", 0.730539172627264, 0.7202883174049193, 0.06744299727584371, 0.03235009840115323],
|
|
39
|
+
["meanTeamCentroidDistance", 793.5978242656994, 810.2038869113109, 114.10938623158178, -0.018770230983947287],
|
|
40
|
+
["openingPathDisplacement", 1926.1863987283657, 1924.3626053132587, 166.35401831829978, -0.725988428680296],
|
|
41
|
+
["openingPathTransitions", 0.5263157894736842, 0.5342869975778368, 0.14190394001266585, 0.012016769453277712],
|
|
42
|
+
["openingPositionEntropy", 0.8362989193520769, 0.8291977740731681, 0.049059785225589556, 0.3891136867077831],
|
|
43
|
+
["fullPositionEntropy", 0.8641613487733687, 0.8540405403082293, 0.05413800821242758, -0.08031875309551142],
|
|
44
|
+
["rejoinsPerMinute", 5.111140689471583, 5.155634195709956, 0.6648081842049797, 0.45060956741302444],
|
|
45
|
+
];
|
|
46
|
+
const FROZEN_INTERCEPT = -0.061863082589897124;
|
|
47
|
+
|
|
48
|
+
function rounded(value: number | null, digits = 6): number | null {
|
|
49
|
+
return value == null ? null : Number(value.toFixed(digits));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function researchFeature(value: number | null): number | null {
|
|
53
|
+
return rounded(value, 12);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function weighted<T>(rows: readonly T[], value: (row: T) => number | null, weight: (row: T) => number): number | null {
|
|
57
|
+
const usable = rows.flatMap((row) => {
|
|
58
|
+
const candidate = value(row);
|
|
59
|
+
const rowWeight = weight(row);
|
|
60
|
+
return candidate != null && Number.isFinite(candidate) && rowWeight > 0 ? [{ value: candidate, weight: rowWeight }] : [];
|
|
61
|
+
});
|
|
62
|
+
const total = usable.reduce((sum, row) => sum + row.weight, 0);
|
|
63
|
+
return total === 0 ? null : usable.reduce((sum, row) => sum + row.value * row.weight, 0) / total;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function entropy(seconds: ReadonlyMap<string, number>): number | null {
|
|
67
|
+
const values = [...seconds.values()].filter((value) => value > 0);
|
|
68
|
+
if (values.length === 0) return null;
|
|
69
|
+
if (values.length === 1) return 0;
|
|
70
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
71
|
+
return -values.reduce((sum, value) => {
|
|
72
|
+
const probability = value / total;
|
|
73
|
+
return sum + probability * Math.log(probability);
|
|
74
|
+
}, 0) / Math.log(values.length);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ComponentTotals {
|
|
78
|
+
covered: number;
|
|
79
|
+
largest: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addComponentWindows(
|
|
83
|
+
target: ComponentTotals,
|
|
84
|
+
windows: TeamShapeRoundFact["windows"],
|
|
85
|
+
playerIndex: number,
|
|
86
|
+
): void {
|
|
87
|
+
for (const window of windows) {
|
|
88
|
+
const component = window.componentPlayerIndices.find((members) => members.includes(playerIndex));
|
|
89
|
+
if (!component || window.coverageSeconds <= 0) continue;
|
|
90
|
+
const largest = Math.max(...window.componentPlayerIndices.map((members) => members.length));
|
|
91
|
+
target.covered += window.coverageSeconds;
|
|
92
|
+
if (component.length === largest) target.largest += window.coverageSeconds;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pathMetrics(row: PlayerPositionRoundFact): { displacement: number | null; transitions: number | null } {
|
|
97
|
+
if (row.openingPath.length < 2) return { displacement: null, transitions: null };
|
|
98
|
+
const first = row.openingPath[0]!;
|
|
99
|
+
const last = row.openingPath.at(-1)!;
|
|
100
|
+
const groups = row.openingPath.flatMap((point) => point.positionGroupId == null ? [] : [point.positionGroupId]);
|
|
101
|
+
return {
|
|
102
|
+
displacement: Math.hypot(last.x - first.x, last.y - first.y, last.z - first.z),
|
|
103
|
+
transitions: groups.slice(1).reduce((sum, group, index) => sum + Number(group !== groups[index]), 0),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function scoreFrozenTResponsibilityFeatures(features: TResponsibilityResearchFeatures): {
|
|
108
|
+
packScore: number;
|
|
109
|
+
lurkerScore: number;
|
|
110
|
+
confidence: number;
|
|
111
|
+
candidate: "pack" | "lurker" | "flexible";
|
|
112
|
+
} {
|
|
113
|
+
let logit = FROZEN_INTERCEPT;
|
|
114
|
+
for (const [key, median, mean, scale, coefficient] of FROZEN_MODEL) {
|
|
115
|
+
const value = features[key] ?? median;
|
|
116
|
+
logit += coefficient * ((value - mean) / scale);
|
|
117
|
+
}
|
|
118
|
+
const packScore = 1 / (1 + Math.exp(-logit));
|
|
119
|
+
const lurkerScore = 1 - packScore;
|
|
120
|
+
const confidence = Math.max(packScore, lurkerScore);
|
|
121
|
+
return {
|
|
122
|
+
packScore: rounded(packScore)!,
|
|
123
|
+
lurkerScore: rounded(lurkerScore)!,
|
|
124
|
+
confidence: rounded(confidence)!,
|
|
125
|
+
candidate: confidence < T_RESPONSIBILITY_RESEARCH_THRESHOLD ? "flexible" : packScore >= lurkerScore ? "pack" : "lurker",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function flatten(facts: MapRoleEvidenceFacts | MatchMapIntelligenceFacts[]): MapRoleEvidenceFacts {
|
|
130
|
+
return Array.isArray(facts)
|
|
131
|
+
? {
|
|
132
|
+
playerPositionRounds: facts.flatMap((fact) => fact.playerPositionRounds),
|
|
133
|
+
teamShapeRounds: facts.flatMap((fact) => fact.teamShapeRounds),
|
|
134
|
+
}
|
|
135
|
+
: facts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function projectionStatus(evidence: readonly PlayerMapRoleEvidence[], rounds: readonly PlayerPositionRoundFact[]): "ready" | "mixed" | "insufficient" | "unknown" {
|
|
139
|
+
if (rounds.length === 0 || rounds.every((row) => row.availability.replay === "missing")) return "unknown";
|
|
140
|
+
const eligible = evidence.reduce((sum, row) => sum + row.sample.eligibleRounds, 0);
|
|
141
|
+
if (eligible < MAP_ROLE_THRESHOLDS.reliableEligibleRounds) return "insufficient";
|
|
142
|
+
const quality = weighted(evidence, (row) => row.sample.dataQuality, (row) => row.sample.eligibleRounds);
|
|
143
|
+
return (quality ?? 0) < 0.72 ? "mixed" : "ready";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Builds an event/corpus-level T projection without changing formal role evidence. */
|
|
147
|
+
export function buildTResponsibilityResearchProjections(
|
|
148
|
+
facts: MapRoleEvidenceFacts | MatchMapIntelligenceFacts[],
|
|
149
|
+
options: MapRoleEvidenceOptions = {},
|
|
150
|
+
): TResponsibilityResearchProjection[] {
|
|
151
|
+
const input = flatten(facts);
|
|
152
|
+
const identityMap = options.identityMap ?? {};
|
|
153
|
+
const teamIdentityMap = options.teamIdentityMap ?? {};
|
|
154
|
+
const evidence = buildPlayerMapRoleEvidence(input, options).filter((row) => row.side === "t");
|
|
155
|
+
const evidenceGroups = new Map<string, PlayerMapRoleEvidence[]>();
|
|
156
|
+
for (const row of evidence) {
|
|
157
|
+
const key = `${row.playerKey}\t${row.teamKey}`;
|
|
158
|
+
evidenceGroups.set(key, [...(evidenceGroups.get(key) ?? []), row]);
|
|
159
|
+
}
|
|
160
|
+
const roundGroups = new Map<string, PlayerPositionRoundFact[]>();
|
|
161
|
+
for (const row of input.playerPositionRounds) {
|
|
162
|
+
if (row.side !== "t") continue;
|
|
163
|
+
const key = `${mapRolePlayerKey(row.steamId64, identityMap)}\t${mapRoleTeamKey(row, teamIdentityMap)}`;
|
|
164
|
+
roundGroups.set(key, [...(roundGroups.get(key) ?? []), row]);
|
|
165
|
+
}
|
|
166
|
+
const shapeByRound = new Map(input.teamShapeRounds.map((row) => [`${row.matchId}\t${row.roundNumber}\t${row.teamKey}\t${row.side}`, row]));
|
|
167
|
+
|
|
168
|
+
return [...evidenceGroups.entries()].map(([key, evidenceRows]) => {
|
|
169
|
+
const [playerKey, teamKey] = key.split("\t") as [string, string];
|
|
170
|
+
const rounds = roundGroups.get(key) ?? [];
|
|
171
|
+
const openingComponents: ComponentTotals = { covered: 0, largest: 0 };
|
|
172
|
+
const fullComponents: ComponentTotals = { covered: 0, largest: 0 };
|
|
173
|
+
const openingDwell = new Map<string, number>();
|
|
174
|
+
const fullDwell = new Map<string, number>();
|
|
175
|
+
const paths = rounds.map((row) => ({ row, ...pathMetrics(row) }));
|
|
176
|
+
for (const row of rounds) {
|
|
177
|
+
const shape = shapeByRound.get(`${row.matchId}\t${row.roundNumber}\t${row.teamKey}\t${row.side}`);
|
|
178
|
+
if (shape) {
|
|
179
|
+
addComponentWindows(openingComponents, shape.openingWindows, row.playerIndex);
|
|
180
|
+
addComponentWindows(fullComponents, shape.windows, row.playerIndex);
|
|
181
|
+
}
|
|
182
|
+
for (const dwell of row.openingPositionGroupDwell) {
|
|
183
|
+
const dwellKey = `${row.mapName}:${dwell.positionGroupId}`;
|
|
184
|
+
openingDwell.set(dwellKey, (openingDwell.get(dwellKey) ?? 0) + dwell.seconds);
|
|
185
|
+
}
|
|
186
|
+
for (const dwell of row.positionGroupDwell) {
|
|
187
|
+
const dwellKey = `${row.mapName}:${dwell.positionGroupId}`;
|
|
188
|
+
fullDwell.set(dwellKey, (fullDwell.get(dwellKey) ?? 0) + dwell.seconds);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const totalEligibleSeconds = rounds.reduce((sum, row) => sum + (row.eligibleSeconds ?? 0), 0);
|
|
192
|
+
const features: TResponsibilityResearchFeatures = {
|
|
193
|
+
dominantGroupStability: researchFeature(weighted(evidenceRows, (row) => row.spatial.dominantGroupStability, (row) => row.sample.eligibleRounds)),
|
|
194
|
+
teamRelativeGroupShare: researchFeature(weighted(evidenceRows, (row) => row.spatial.teamRelativeGroupShare, (row) => row.sample.eligibleRounds)),
|
|
195
|
+
openingIsolatedShare: researchFeature(weighted(evidenceRows, (row) => row.spatial.openingIsolatedShare, (row) => row.sample.eligibleRounds)),
|
|
196
|
+
isolationShare: researchFeature(weighted(evidenceRows, (row) => row.spatial.isolationShare, (row) => row.sample.eligibleRounds)),
|
|
197
|
+
delayedConvergenceShare: researchFeature(weighted(evidenceRows, (row) => row.spatial.delayedConvergenceRoundShare, (row) => row.sample.eligibleRounds)),
|
|
198
|
+
movementSync: researchFeature(weighted(evidenceRows, (row) => row.spatial.movementSync, (row) => row.sample.eligibleRounds)),
|
|
199
|
+
positionTopShare: researchFeature(weighted(evidenceRows, (row) => row.positionGroups[0]?.share ?? null, (row) => row.sample.eligibleRounds)),
|
|
200
|
+
openingLargestShare: openingComponents.covered === 0 ? null : researchFeature(openingComponents.largest / openingComponents.covered),
|
|
201
|
+
fullLargestShare: fullComponents.covered === 0 ? null : researchFeature(fullComponents.largest / fullComponents.covered),
|
|
202
|
+
meanTeamCentroidDistance: researchFeature(weighted(rounds, (row) => row.meanTeamCentroidDistance, (row) => row.eligibleSeconds ?? 0)),
|
|
203
|
+
openingPathDisplacement: researchFeature(weighted(paths, (row) => row.displacement, (row) => row.row.openingEligibleSeconds ?? 0)),
|
|
204
|
+
openingPathTransitions: researchFeature(weighted(paths, (row) => row.transitions, (row) => row.row.openingEligibleSeconds ?? 0)),
|
|
205
|
+
openingPositionEntropy: researchFeature(entropy(openingDwell)),
|
|
206
|
+
fullPositionEntropy: researchFeature(entropy(fullDwell)),
|
|
207
|
+
rejoinsPerMinute: totalEligibleSeconds === 0 ? null : researchFeature(60 * rounds.reduce((sum, row) => sum + row.rejoinTicks.length, 0) / totalEligibleSeconds),
|
|
208
|
+
};
|
|
209
|
+
const status = projectionStatus(evidenceRows, rounds);
|
|
210
|
+
const score = status === "unknown" || status === "insufficient" ? null : scoreFrozenTResponsibilityFeatures(features);
|
|
211
|
+
return tResponsibilityResearchProjectionSchema.parse({
|
|
212
|
+
version: T_RESPONSIBILITY_RESEARCH_PROJECTION_VERSION,
|
|
213
|
+
modelId: T_RESPONSIBILITY_RESEARCH_MODEL_ID,
|
|
214
|
+
playerKey,
|
|
215
|
+
teamKey,
|
|
216
|
+
side: "t",
|
|
217
|
+
status,
|
|
218
|
+
candidate: score?.candidate ?? null,
|
|
219
|
+
packScore: score?.packScore ?? null,
|
|
220
|
+
lurkerScore: score?.lurkerScore ?? null,
|
|
221
|
+
confidence: score?.confidence ?? null,
|
|
222
|
+
sample: {
|
|
223
|
+
observedRounds: rounds.length,
|
|
224
|
+
eligibleRounds: evidenceRows.reduce((sum, row) => sum + row.sample.eligibleRounds, 0),
|
|
225
|
+
eligibleSeconds: rounded(totalEligibleSeconds, 3)!,
|
|
226
|
+
matchCount: new Set(rounds.map((row) => row.matchId)).size,
|
|
227
|
+
mapCount: new Set(rounds.map((row) => row.mapName)).size,
|
|
228
|
+
dataQuality: rounded(weighted(evidenceRows, (row) => row.sample.dataQuality, (row) => row.sample.eligibleRounds)),
|
|
229
|
+
},
|
|
230
|
+
features,
|
|
231
|
+
matchIds: [...new Set(rounds.map((row) => row.matchId))].sort(),
|
|
232
|
+
representativeRounds: evidenceRows.flatMap((row) => row.representativeRounds).sort((a, b) => a.matchId.localeCompare(b.matchId) || a.roundNumber - b.roundNumber).slice(0, 8),
|
|
233
|
+
basis: ["科隆 Major 冻结 15 特征同赛事 full-fit 诊断投影;正式 responsibility 保持不变。"],
|
|
234
|
+
limitations: [
|
|
235
|
+
"尚无新赛事外部验证;packScore / lurkerScore 不是校准概率。",
|
|
236
|
+
"主狙职责必须由调用方依据独立 weapon duty 处理。",
|
|
237
|
+
"Flexible 表示默认 0.60 confidence threshold 下 abstain,不是拟合的第三类。",
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
}).sort((a, b) => a.teamKey.localeCompare(b.teamKey) || a.playerKey.localeCompare(b.playerKey));
|
|
241
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { OpeningPattern } from "@cs2dak/core";
|
|
2
|
+
|
|
3
|
+
export type TacticalExecuteBucket = "rush" | "fast" | "mid" | "late";
|
|
4
|
+
export type EconomyEntry = "pistol" | "gun" | "anti_eco" | "force" | "semi" | "eco";
|
|
5
|
+
|
|
6
|
+
export interface TacticalPatternRow {
|
|
7
|
+
side: "t" | "ct";
|
|
8
|
+
targetSite: "a" | "b" | null;
|
|
9
|
+
matchId: string;
|
|
10
|
+
mapName: string;
|
|
11
|
+
teamKey: string;
|
|
12
|
+
/** 调用方可提供跨场 canonical identity;缺省时兼容回退到规范化原始名称。 */
|
|
13
|
+
teamIdentity?: string;
|
|
14
|
+
opponentIdentity?: string;
|
|
15
|
+
teamName: string;
|
|
16
|
+
opponentName: string;
|
|
17
|
+
economy: string;
|
|
18
|
+
opponentEconomy: string;
|
|
19
|
+
won: boolean;
|
|
20
|
+
roundNumber: number;
|
|
21
|
+
openingPattern: OpeningPattern;
|
|
22
|
+
siteEntries: {
|
|
23
|
+
a: { entrants: number; order: Array<{ entryCallout: string | null; entryChokeId?: string | null; routeFamilyId?: string | null }> };
|
|
24
|
+
b: { entrants: number; order: Array<{ entryCallout: string | null; entryChokeId?: string | null; routeFamilyId?: string | null }> };
|
|
25
|
+
};
|
|
26
|
+
plant: unknown | null;
|
|
27
|
+
grenades: Array<{ type: string; targetRegion: "a" | "b" | "mid" | "other" | "unknown" }>;
|
|
28
|
+
c4Route: { endRegion: "a" | "b" | "mid" | "other" | null; rotated: boolean } | null;
|
|
29
|
+
executeBucket: TacticalExecuteBucket | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TacticalEntryEvidenceRoute {
|
|
33
|
+
site: "a" | "b";
|
|
34
|
+
combo: string;
|
|
35
|
+
roundCount: number;
|
|
36
|
+
percentOfCovered: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TacticalEntryEvidence {
|
|
40
|
+
coveredRounds: number;
|
|
41
|
+
totalRounds: number;
|
|
42
|
+
coveragePercent: number;
|
|
43
|
+
routes: TacticalEntryEvidenceRoute[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TacticalCluster {
|
|
47
|
+
id: string;
|
|
48
|
+
mapName: string;
|
|
49
|
+
side: TacticalPatternRow["side"];
|
|
50
|
+
economyEntry: EconomyEntry;
|
|
51
|
+
/** 当前事实中可用的跨场队伍身份(规范化 teamName)。 */
|
|
52
|
+
teamIdentity: string;
|
|
53
|
+
teamName: string;
|
|
54
|
+
opponentNames: string[];
|
|
55
|
+
opponentIdentities: string[];
|
|
56
|
+
/** 真实 OpeningPattern 的区域人数与 spread,不含任何最终打点事实。 */
|
|
57
|
+
openingIntent: Pick<OpeningPattern, "regionCounts" | "spread">;
|
|
58
|
+
/** 真实默认位人数结构;精确人数是开局身份的一部分。 */
|
|
59
|
+
positionGroupCounts: Record<string, number>;
|
|
60
|
+
primaryCategory: string;
|
|
61
|
+
openingSignature: string;
|
|
62
|
+
entryEvidence: TacticalEntryEvidence;
|
|
63
|
+
roundCount: number;
|
|
64
|
+
winRatePercent: number | null;
|
|
65
|
+
plantRatePercent: number | null;
|
|
66
|
+
rounds: Array<{ matchId: string; roundNumber: number; teamKey: string; won: boolean; economy: string; planted: boolean }>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function economyEntryOf(economy: string, opponentEconomy: string): EconomyEntry {
|
|
70
|
+
if (economy === "pistol") return "pistol";
|
|
71
|
+
if (economy === "full") return opponentEconomy === "full" ? "gun" : "anti_eco";
|
|
72
|
+
if (economy === "force") return "force";
|
|
73
|
+
if (economy === "semi") return "semi";
|
|
74
|
+
return "eco";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defaultsBasisKey(defaults: Record<string, number>): string {
|
|
78
|
+
return Object.entries(defaults)
|
|
79
|
+
.filter(([, count]) => count > 0)
|
|
80
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
81
|
+
.map(([id, count]) => `${id}:${count}`)
|
|
82
|
+
.join("|");
|
|
83
|
+
}
|
|
84
|
+
export const advancedBasisKey = defaultsBasisKey;
|
|
85
|
+
|
|
86
|
+
/** 稳定默认位身份:按位置责任组 id 排序,并保留真实人数结构。 */
|
|
87
|
+
export function positionGroupSetKey(groups: Record<string, number>): string {
|
|
88
|
+
return defaultsBasisKey(groups);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function openingIntentKey(pattern: OpeningPattern): string {
|
|
92
|
+
const { a, mid, b } = pattern.regionCounts;
|
|
93
|
+
return `${a}A-${mid}MID-${b}B:${pattern.spread}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function teamIdentityOf(row: TacticalPatternRow): string {
|
|
97
|
+
return row.teamIdentity ?? row.teamName.trim().toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function opponentIdentityOf(row: TacticalPatternRow): string {
|
|
101
|
+
return row.opponentIdentity ?? row.opponentName.trim().toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function openingPatternKey(row: TacticalPatternRow): string {
|
|
105
|
+
return [row.mapName, row.side, openingIntentKey(row.openingPattern), positionGroupSetKey(row.openingPattern.positionGroupCounts)].join(":");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 单回合真实进点证据。缺少目标点或入口时返回 null,不用 fallback 猜测。 */
|
|
109
|
+
export function entryEvidenceKey(row: TacticalPatternRow): { site: "a" | "b"; combo: string } | null {
|
|
110
|
+
if (row.side !== "t") return null;
|
|
111
|
+
const site = row.targetSite;
|
|
112
|
+
if (!site) return null;
|
|
113
|
+
const ids = new Set<string>();
|
|
114
|
+
for (const occurrence of row.siteEntries[site].order) {
|
|
115
|
+
const id = occurrence.entryChokeId ?? occurrence.routeFamilyId;
|
|
116
|
+
if (id) ids.add(id);
|
|
117
|
+
}
|
|
118
|
+
if (ids.size === 0) return null;
|
|
119
|
+
return { site, combo: [...ids].sort().join("+") };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 兼容既有调用:该函数现在只读取真实进点 evidence,不参与聚类 key。 */
|
|
123
|
+
export function chokeComboOf(row: TacticalPatternRow, site: "a" | "b" | null): string | null {
|
|
124
|
+
const evidence = entryEvidenceKey(row);
|
|
125
|
+
return evidence?.site === site ? evidence.combo : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function tacticalClusterKey(row: TacticalPatternRow): string {
|
|
129
|
+
return [
|
|
130
|
+
row.side,
|
|
131
|
+
row.mapName,
|
|
132
|
+
teamIdentityOf(row),
|
|
133
|
+
economyEntryOf(row.economy, row.opponentEconomy),
|
|
134
|
+
openingIntentKey(row.openingPattern),
|
|
135
|
+
positionGroupSetKey(row.openingPattern.positionGroupCounts) || "-",
|
|
136
|
+
].join("|");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function primaryCategoryOf(pattern: OpeningPattern): string {
|
|
140
|
+
const { a, mid, b } = pattern.regionCounts;
|
|
141
|
+
const max = Math.max(a, mid, b);
|
|
142
|
+
const leaders = [a === max ? "A侧" : null, mid === max ? "中路" : null, b === max ? "B侧" : null].filter(Boolean);
|
|
143
|
+
return leaders.length === 1 ? `${leaders[0]}控图` : "均衡控图";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildTacticalClusters(rows: readonly TacticalPatternRow[]): TacticalCluster[] {
|
|
147
|
+
const clusters = new Map<string, TacticalCluster>();
|
|
148
|
+
const entryCounts = new Map<string, Map<string, number>>();
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const id = tacticalClusterKey(row);
|
|
151
|
+
const cluster: TacticalCluster = clusters.get(id) ?? {
|
|
152
|
+
id,
|
|
153
|
+
mapName: row.mapName,
|
|
154
|
+
side: row.side,
|
|
155
|
+
economyEntry: economyEntryOf(row.economy, row.opponentEconomy),
|
|
156
|
+
teamIdentity: teamIdentityOf(row),
|
|
157
|
+
teamName: row.teamName,
|
|
158
|
+
opponentNames: [row.opponentName],
|
|
159
|
+
opponentIdentities: [opponentIdentityOf(row)],
|
|
160
|
+
openingIntent: {
|
|
161
|
+
regionCounts: { ...row.openingPattern.regionCounts },
|
|
162
|
+
spread: row.openingPattern.spread,
|
|
163
|
+
},
|
|
164
|
+
positionGroupCounts: { ...row.openingPattern.positionGroupCounts },
|
|
165
|
+
primaryCategory: primaryCategoryOf(row.openingPattern),
|
|
166
|
+
openingSignature: openingPatternKey(row),
|
|
167
|
+
entryEvidence: { coveredRounds: 0, totalRounds: 0, coveragePercent: 0, routes: [] },
|
|
168
|
+
roundCount: 0,
|
|
169
|
+
winRatePercent: null,
|
|
170
|
+
plantRatePercent: null,
|
|
171
|
+
rounds: [],
|
|
172
|
+
};
|
|
173
|
+
cluster.roundCount += 1;
|
|
174
|
+
if (!cluster.opponentNames.includes(row.opponentName)) {
|
|
175
|
+
cluster.opponentNames.push(row.opponentName);
|
|
176
|
+
cluster.opponentNames.sort((a, b) => a.localeCompare(b));
|
|
177
|
+
}
|
|
178
|
+
const opponentIdentity = opponentIdentityOf(row);
|
|
179
|
+
if (!cluster.opponentIdentities.includes(opponentIdentity)) cluster.opponentIdentities.push(opponentIdentity);
|
|
180
|
+
cluster.rounds.push({
|
|
181
|
+
matchId: row.matchId,
|
|
182
|
+
roundNumber: row.roundNumber,
|
|
183
|
+
teamKey: row.teamKey,
|
|
184
|
+
won: row.won,
|
|
185
|
+
economy: row.economy,
|
|
186
|
+
planted: row.plant != null,
|
|
187
|
+
});
|
|
188
|
+
const evidence = entryEvidenceKey(row);
|
|
189
|
+
if (evidence) {
|
|
190
|
+
const counts = entryCounts.get(id) ?? new Map<string, number>();
|
|
191
|
+
const routeKey = `${evidence.site}:${evidence.combo}`;
|
|
192
|
+
counts.set(routeKey, (counts.get(routeKey) ?? 0) + 1);
|
|
193
|
+
entryCounts.set(id, counts);
|
|
194
|
+
}
|
|
195
|
+
clusters.set(id, cluster);
|
|
196
|
+
}
|
|
197
|
+
const percent = (numerator: number, denominator: number) =>
|
|
198
|
+
denominator > 0 ? Math.round((numerator / denominator) * 1000) / 10 : 0;
|
|
199
|
+
return [...clusters.values()]
|
|
200
|
+
.map((cluster) => {
|
|
201
|
+
const counts = entryCounts.get(cluster.id) ?? new Map<string, number>();
|
|
202
|
+
const coveredRounds = [...counts.values()].reduce((sum, count) => sum + count, 0);
|
|
203
|
+
return {
|
|
204
|
+
...cluster,
|
|
205
|
+
winRatePercent: percent(cluster.rounds.filter((round) => round.won).length, cluster.roundCount),
|
|
206
|
+
plantRatePercent: percent(cluster.rounds.filter((round) => round.planted).length, cluster.roundCount),
|
|
207
|
+
entryEvidence: {
|
|
208
|
+
coveredRounds,
|
|
209
|
+
totalRounds: cluster.roundCount,
|
|
210
|
+
coveragePercent: percent(coveredRounds, cluster.roundCount),
|
|
211
|
+
routes: [...counts.entries()]
|
|
212
|
+
.map(([key, roundCount]) => ({
|
|
213
|
+
site: key.slice(0, 1) as "a" | "b",
|
|
214
|
+
combo: key.slice(2),
|
|
215
|
+
roundCount,
|
|
216
|
+
percentOfCovered: percent(roundCount, coveredRounds),
|
|
217
|
+
}))
|
|
218
|
+
.sort((a, b) => b.roundCount - a.roundCount || a.site.localeCompare(b.site) || a.combo.localeCompare(b.combo)),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
})
|
|
222
|
+
.sort((a, b) => b.roundCount - a.roundCount || a.id.localeCompare(b.id));
|
|
223
|
+
}
|