@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.
- package/LICENSE +7 -0
- package/package.json +5 -5
- package/src/index.test.ts +150 -0
- package/src/index.ts +216 -75
- 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
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { DemoPackage, Side } from "@cs2dak/contract";
|
|
2
|
+
import { FLAG_ALIVE } from "@cs2dak/contract";
|
|
3
|
+
import { createResolverFromPackage } from "@cs2dak/core";
|
|
4
|
+
|
|
5
|
+
export interface OpeningPatternInput {
|
|
6
|
+
matchId: string;
|
|
7
|
+
pkg: DemoPackage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OpeningPatternCluster {
|
|
11
|
+
id: string;
|
|
12
|
+
mapName: string;
|
|
13
|
+
side: Side;
|
|
14
|
+
windowSeconds: number;
|
|
15
|
+
basis: string;
|
|
16
|
+
roundCount: number;
|
|
17
|
+
winRatePercent: number | null;
|
|
18
|
+
grenadeSequence: string[];
|
|
19
|
+
rounds: Array<{ matchId: string; roundNumber: number; won: boolean }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpeningPatternOptions {
|
|
23
|
+
windowSeconds?: 15 | 20 | 30;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function round(value: number, digits = 1): number {
|
|
27
|
+
const factor = 10 ** digits;
|
|
28
|
+
return Math.round(value * factor) / factor;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function distributionKey(labels: string[]): string {
|
|
32
|
+
const counts = new Map<string, number>();
|
|
33
|
+
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
34
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([label, count]) => `${label}:${count}`).join("|");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function grenadeKey(pkg: DemoPackage, round: { roundNumber: number; teamASide: Side; teamBSide: Side }, side: Side, windowStart: number, windowEnd: number): string[] {
|
|
38
|
+
const resolver = createResolverFromPackage(pkg);
|
|
39
|
+
return pkg.grenades
|
|
40
|
+
.filter((grenade) => {
|
|
41
|
+
if (grenade.roundNumber !== round.roundNumber) return false;
|
|
42
|
+
if (grenade.throwTick < windowStart || grenade.throwTick > windowEnd) return false;
|
|
43
|
+
return resolver.sideOf(grenade.throwerIndex, round.roundNumber) === side;
|
|
44
|
+
})
|
|
45
|
+
.sort((a, b) => a.throwTick - b.throwTick)
|
|
46
|
+
.map((grenade) => grenade.grenade);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function replayLabelsAt(
|
|
50
|
+
pkg: DemoPackage,
|
|
51
|
+
round: { roundNumber: number; teamASide: Side; teamBSide: Side },
|
|
52
|
+
side: Side,
|
|
53
|
+
sampleTick: number
|
|
54
|
+
): string[] {
|
|
55
|
+
const replay = pkg.replay;
|
|
56
|
+
const replayRound = replay?.rounds.find((row) => row.roundNumber === round.roundNumber);
|
|
57
|
+
if (!replay || !replayRound) return [];
|
|
58
|
+
const labels: string[] = [];
|
|
59
|
+
for (const track of replayRound.players) {
|
|
60
|
+
const player = pkg.players[track.playerIndex];
|
|
61
|
+
if (!player) continue;
|
|
62
|
+
const playerSide = player.teamKey === "teamA" ? round.teamASide : round.teamBSide;
|
|
63
|
+
if (playerSide !== side) continue;
|
|
64
|
+
const frameIndex = Math.max(
|
|
65
|
+
0,
|
|
66
|
+
Math.min(replayRound.frameCount - 1, Math.round((sampleTick - replayRound.startTick) / replayRound.tickStep))
|
|
67
|
+
);
|
|
68
|
+
if (((track.flags[frameIndex] ?? 0) & FLAG_ALIVE) === 0) continue;
|
|
69
|
+
const place = replay.placeDict?.[track.place[frameIndex] ?? -1];
|
|
70
|
+
if (place) labels.push(place);
|
|
71
|
+
}
|
|
72
|
+
return labels.sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildOpeningPatternClusters(
|
|
76
|
+
demos: OpeningPatternInput[],
|
|
77
|
+
opts: OpeningPatternOptions = {}
|
|
78
|
+
): OpeningPatternCluster[] {
|
|
79
|
+
const windowSeconds = opts.windowSeconds ?? 15;
|
|
80
|
+
const clusters = new Map<string, OpeningPatternCluster>();
|
|
81
|
+
|
|
82
|
+
for (const { matchId, pkg } of demos) {
|
|
83
|
+
const tickrate = pkg.match.tickrate || 64;
|
|
84
|
+
for (const round of pkg.rounds) {
|
|
85
|
+
const sampleTick = round.freezeEndTick + windowSeconds * tickrate;
|
|
86
|
+
for (const side of ["t", "ct"] as const) {
|
|
87
|
+
const labels = replayLabelsAt(pkg, round, side, sampleTick);
|
|
88
|
+
if (labels.length === 0) continue;
|
|
89
|
+
const basis = distributionKey(labels);
|
|
90
|
+
const grenades = grenadeKey(pkg, round, side, round.freezeEndTick, sampleTick);
|
|
91
|
+
const key = `${pkg.match.mapName}:${side}:${windowSeconds}:${basis}:${grenades.join(">")}`;
|
|
92
|
+
const won = round.winnerSide === side;
|
|
93
|
+
const cluster = clusters.get(key) ?? {
|
|
94
|
+
id: key,
|
|
95
|
+
mapName: pkg.match.mapName,
|
|
96
|
+
side,
|
|
97
|
+
windowSeconds,
|
|
98
|
+
basis,
|
|
99
|
+
roundCount: 0,
|
|
100
|
+
winRatePercent: null,
|
|
101
|
+
grenadeSequence: grenades,
|
|
102
|
+
rounds: []
|
|
103
|
+
};
|
|
104
|
+
cluster.roundCount += 1;
|
|
105
|
+
cluster.rounds.push({ matchId, roundNumber: round.roundNumber, won });
|
|
106
|
+
clusters.set(key, cluster);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...clusters.values()]
|
|
112
|
+
.map((cluster) => {
|
|
113
|
+
const wins = cluster.rounds.filter((row) => row.won).length;
|
|
114
|
+
return { ...cluster, winRatePercent: cluster.roundCount > 0 ? round(wins / cluster.roundCount * 100, 1) : null };
|
|
115
|
+
})
|
|
116
|
+
.sort((a, b) => b.roundCount - a.roundCount || a.id.localeCompare(b.id));
|
|
117
|
+
}
|
|
@@ -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
|
+
}
|