@cs2dak/core 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 +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +68 -88
- package/src/index.ts +41 -9
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +26 -149
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/side-win-rate.test.ts +33 -0
- package/src/side-win-rate.ts +49 -0
- package/src/signals.ts +150 -113
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +55 -0
- package/src/economy.test.ts +0 -51
- package/src/economy.ts +0 -76
- package/src/weapons.test.ts +0 -32
- package/src/weapons.ts +0 -93
- package/src/workspace.ts +0 -726
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAP_INTELLIGENCE_FACT_VERSION,
|
|
3
|
+
type MapIntelligenceAvailability,
|
|
4
|
+
type PlayerPositionRoundFact,
|
|
5
|
+
type DemoPackage,
|
|
6
|
+
} from "@cs2dak/contract";
|
|
7
|
+
import { positionGroupOf, type CalloutGrid } from "@cs2dak/maps";
|
|
8
|
+
import { extractAwpRoundFacts } from "./awp.js";
|
|
9
|
+
import { mean, rounded, type TeamSpatialFrame } from "./spatial.js";
|
|
10
|
+
import type { ReplayRoundContext, ReplayRoundTrack } from "../tactics/replay-round-context.js";
|
|
11
|
+
import { openingResponsibilityWindow } from "./opening-window.js";
|
|
12
|
+
|
|
13
|
+
function availability(context: ReplayRoundContext | null, track: ReplayRoundTrack | null, grid: CalloutGrid | null, hasNav: boolean, hasShots: boolean): MapIntelligenceAvailability {
|
|
14
|
+
return {
|
|
15
|
+
replay: context && track ? "available" : context ? "degraded" : "missing",
|
|
16
|
+
nav: hasNav ? "available" : "missing",
|
|
17
|
+
callouts: context && (context.placeDict.length > 0 || grid) ? "available" : context ? "degraded" : "missing",
|
|
18
|
+
shots: hasShots ? "available" : "missing",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function distances(frame: TeamSpatialFrame, playerIndex: number): { nearest: number | null; centroid: number | null; componentSize: number | null } {
|
|
23
|
+
const player = frame.players.find((row) => row.playerIndex === playerIndex);
|
|
24
|
+
if (!player) return { nearest: null, centroid: null, componentSize: null };
|
|
25
|
+
const teammates = frame.players.filter((row) => row.playerIndex !== playerIndex);
|
|
26
|
+
const distance = (first: typeof player.point, second: typeof player.point) => Math.hypot(first.x - second.x, first.y - second.y, first.z - second.z);
|
|
27
|
+
const nearest = teammates.length ? Math.min(...teammates.map((row) => distance(player.point, row.point))) : null;
|
|
28
|
+
const centroid = teammates.length
|
|
29
|
+
? { x: teammates.reduce((sum, row) => sum + row.point.x, 0) / teammates.length, y: teammates.reduce((sum, row) => sum + row.point.y, 0) / teammates.length, z: teammates.reduce((sum, row) => sum + row.point.z, 0) / teammates.length }
|
|
30
|
+
: null;
|
|
31
|
+
const componentSize = frame.components.find((component) => component.includes(playerIndex))?.length ?? null;
|
|
32
|
+
return { nearest, centroid: centroid ? distance(player.point, centroid) : null, componentSize };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function movementSync(frames: readonly TeamSpatialFrame[], playerIndex: number): number | null {
|
|
36
|
+
const values: number[] = [];
|
|
37
|
+
for (let index = 1; index < frames.length; index += 1) {
|
|
38
|
+
const before = frames[index - 1]!.players.find((row) => row.playerIndex === playerIndex);
|
|
39
|
+
const after = frames[index]!.players.find((row) => row.playerIndex === playerIndex);
|
|
40
|
+
if (!before || !after) continue;
|
|
41
|
+
const teamBefore = frames[index - 1]!.players.filter((row) => row.playerIndex !== playerIndex);
|
|
42
|
+
const teamAfter = frames[index]!.players.filter((row) => row.playerIndex !== playerIndex);
|
|
43
|
+
if (teamBefore.length === 0 || teamBefore.length !== teamAfter.length) continue;
|
|
44
|
+
const self = { x: after.point.x - before.point.x, y: after.point.y - before.point.y, z: after.point.z - before.point.z };
|
|
45
|
+
const team = teamAfter.reduce((sum, row, teammateIndex) => ({ x: sum.x + row.point.x - teamBefore[teammateIndex]!.point.x, y: sum.y + row.point.y - teamBefore[teammateIndex]!.point.y, z: sum.z + row.point.z - teamBefore[teammateIndex]!.point.z }), { x: 0, y: 0, z: 0 });
|
|
46
|
+
const selfLength = Math.hypot(self.x, self.y, self.z);
|
|
47
|
+
const teamLength = Math.hypot(team.x, team.y, team.z);
|
|
48
|
+
if (selfLength === 0 || teamLength === 0) continue;
|
|
49
|
+
values.push((self.x * team.x + self.y * team.y + self.z * team.z) / (selfLength * teamLength));
|
|
50
|
+
}
|
|
51
|
+
return rounded(mean(values));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MIN_DELAYED_ISOLATION_SECONDS = 2;
|
|
55
|
+
const MIN_TARGET_STABILITY_SECONDS = 1;
|
|
56
|
+
const MIN_CONVERGENCE_PERSISTENCE_SECONDS = 2;
|
|
57
|
+
|
|
58
|
+
function samePlayers(first: TeamSpatialFrame, second: TeamSpatialFrame): boolean {
|
|
59
|
+
const a = first.players.map((row) => row.playerIndex).sort((x, y) => x - y);
|
|
60
|
+
const b = second.players.map((row) => row.playerIndex).sort((x, y) => x - y);
|
|
61
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Conservative per-round convergence evidence; raw rejoin ticks remain separately available. */
|
|
65
|
+
export function detectDelayedConvergences(
|
|
66
|
+
frames: readonly TeamSpatialFrame[],
|
|
67
|
+
playerIndex: number,
|
|
68
|
+
tickrate: number,
|
|
69
|
+
tickStep: number,
|
|
70
|
+
): PlayerPositionRoundFact["delayedConvergences"] {
|
|
71
|
+
const result: PlayerPositionRoundFact["delayedConvergences"] = [];
|
|
72
|
+
let isolatedSince: number | null = null;
|
|
73
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
74
|
+
const frame = frames[index]!;
|
|
75
|
+
const component = frame.components.find((group) => group.includes(playerIndex));
|
|
76
|
+
const isolated = component?.length === 1 && frame.players.length > 1;
|
|
77
|
+
if (isolated) {
|
|
78
|
+
isolatedSince ??= frame.tick;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (isolatedSince == null || !component || component.length < 3 || index === 0) {
|
|
82
|
+
isolatedSince = null;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const priorIsolationSeconds = (frame.tick - isolatedSince) / tickrate;
|
|
86
|
+
const before = frames[index - 1]!;
|
|
87
|
+
if (priorIsolationSeconds < MIN_DELAYED_ISOLATION_SECONDS || !samePlayers(before, frame)) {
|
|
88
|
+
isolatedSince = null;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const target = component.filter((member) => member !== playerIndex);
|
|
92
|
+
const priorStartTick = frame.tick - MIN_TARGET_STABILITY_SECONDS * tickrate;
|
|
93
|
+
const priorFrames = frames.slice(0, index).filter((candidate) => candidate.tick >= priorStartTick);
|
|
94
|
+
const hasPriorCoverage = priorFrames.length > 0 && frame.tick - priorFrames[0]!.tick + tickStep >= MIN_TARGET_STABILITY_SECONDS * tickrate;
|
|
95
|
+
const targetWasStable = hasPriorCoverage && priorFrames.every((candidate) => {
|
|
96
|
+
const group = candidate.components.find((members) => members.includes(target[0]!));
|
|
97
|
+
return group != null && target.every((member) => group.includes(member));
|
|
98
|
+
});
|
|
99
|
+
if (!targetWasStable) {
|
|
100
|
+
isolatedSince = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let persistedFrames = 0;
|
|
104
|
+
for (let cursor = index; cursor < frames.length; cursor += 1) {
|
|
105
|
+
const candidate = frames[cursor]!;
|
|
106
|
+
if (!samePlayers(frame, candidate)) break;
|
|
107
|
+
const joined = candidate.components.find((group) => group.includes(playerIndex));
|
|
108
|
+
if (!joined || target.filter((member) => joined.includes(member)).length < 2) break;
|
|
109
|
+
persistedFrames += 1;
|
|
110
|
+
}
|
|
111
|
+
const persistenceSeconds = persistedFrames * tickStep / tickrate;
|
|
112
|
+
if (persistenceSeconds >= MIN_CONVERGENCE_PERSISTENCE_SECONDS) {
|
|
113
|
+
result.push({ tick: frame.tick, priorIsolationSeconds: rounded(priorIsolationSeconds)!, joinedComponentSize: component.length, persistenceSeconds: rounded(persistenceSeconds)! });
|
|
114
|
+
}
|
|
115
|
+
isolatedSince = null;
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function extractPlayerPositionRoundFacts(
|
|
121
|
+
pkg: DemoPackage,
|
|
122
|
+
matchId: string,
|
|
123
|
+
context: ReplayRoundContext | null,
|
|
124
|
+
round: DemoPackage["rounds"][number],
|
|
125
|
+
frames: readonly TeamSpatialFrame[],
|
|
126
|
+
grid: CalloutGrid | null,
|
|
127
|
+
hasNav: boolean,
|
|
128
|
+
): PlayerPositionRoundFact[] {
|
|
129
|
+
const economyType = (teamKey: "teamA" | "teamB") => teamKey === "teamA" ? round.teamAEconomy : round.teamBEconomy;
|
|
130
|
+
const configuredOpeningWindow = openingResponsibilityWindow(round, pkg.match.tickrate || 64);
|
|
131
|
+
const utilityCounts = (playerIndex: number) => {
|
|
132
|
+
const events = pkg.grenades.filter((grenade) => grenade.roundNumber === round.roundNumber && grenade.throwerIndex === playerIndex);
|
|
133
|
+
const tickOf = (grenade: (typeof events)[number]) => grenade.throwTick ?? grenade.effectTick;
|
|
134
|
+
return {
|
|
135
|
+
utilityUseCount: events.length,
|
|
136
|
+
openingUtilityUseCount: events.filter((grenade) => {
|
|
137
|
+
const tick = tickOf(grenade);
|
|
138
|
+
return tick >= configuredOpeningWindow.startTick && tick < configuredOpeningWindow.endTick;
|
|
139
|
+
}).length,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
if (!context) {
|
|
143
|
+
return pkg.players.map((player, playerIndex) => ({
|
|
144
|
+
analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId, mapName: pkg.match.mapName, roundNumber: round.roundNumber,
|
|
145
|
+
teamKey: player.teamKey, side: player.teamKey === "teamA" ? round.teamASide : round.teamBSide,
|
|
146
|
+
playerIndex, steamId64: player.steamId64, economyType: economyType(player.teamKey), openingWindow: null,
|
|
147
|
+
openingEligibleSeconds: null, openingPositionGroupDwell: [], openingMeanComponentSize: null, openingIsolationSeconds: null,
|
|
148
|
+
...utilityCounts(playerIndex),
|
|
149
|
+
openingPath: [],
|
|
150
|
+
eligibleSeconds: null, positionGroupDwell: [],
|
|
151
|
+
unresolvedCalloutSeconds: null, calloutCoverage: null, meanNearestTeammateDistance: null, meanTeamCentroidDistance: null,
|
|
152
|
+
meanComponentSize: null, isolationSegments: [], rejoinTicks: [], delayedConvergences: [], movementSync: null,
|
|
153
|
+
freezeAwpOwnership: null, activeAwpSeconds: null, awpShots: null, awpKills: null,
|
|
154
|
+
availability: availability(null, null, grid, hasNav, Boolean(pkg.shots)),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
const frameSeconds = context.tickStep / (pkg.match.tickrate || 64);
|
|
158
|
+
return pkg.players.map((player, playerIndex) => {
|
|
159
|
+
const track = context.tracks.find((row) => row.playerIndex === playerIndex) ?? null;
|
|
160
|
+
const side = player.teamKey === "teamA" ? round.teamASide : round.teamBSide;
|
|
161
|
+
if (!track) {
|
|
162
|
+
return {
|
|
163
|
+
analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId, mapName: pkg.match.mapName, roundNumber: round.roundNumber,
|
|
164
|
+
teamKey: player.teamKey, side, playerIndex, steamId64: player.steamId64, economyType: economyType(player.teamKey), openingWindow: null,
|
|
165
|
+
openingEligibleSeconds: null, openingPositionGroupDwell: [], openingMeanComponentSize: null, openingIsolationSeconds: null,
|
|
166
|
+
...utilityCounts(playerIndex),
|
|
167
|
+
openingPath: [],
|
|
168
|
+
eligibleSeconds: null, positionGroupDwell: [],
|
|
169
|
+
unresolvedCalloutSeconds: null, calloutCoverage: null, meanNearestTeammateDistance: null, meanTeamCentroidDistance: null,
|
|
170
|
+
meanComponentSize: null, isolationSegments: [], rejoinTicks: [], delayedConvergences: [], movementSync: null,
|
|
171
|
+
freezeAwpOwnership: null, activeAwpSeconds: null, awpShots: null, awpKills: null,
|
|
172
|
+
availability: availability(context, null, grid, hasNav, Boolean(pkg.shots)),
|
|
173
|
+
} satisfies PlayerPositionRoundFact;
|
|
174
|
+
}
|
|
175
|
+
const ownFrames = frames.filter((frame) => frame.teamKey === player.teamKey && frame.players.some((row) => row.playerIndex === playerIndex));
|
|
176
|
+
const openingWindow = configuredOpeningWindow;
|
|
177
|
+
const openingFrames = ownFrames.filter((frame) => frame.tick >= openingWindow.startTick && frame.tick < openingWindow.endTick);
|
|
178
|
+
const dwell = new Map<string, number>();
|
|
179
|
+
let unresolved = 0;
|
|
180
|
+
const nearest: number[] = [];
|
|
181
|
+
const centroid: number[] = [];
|
|
182
|
+
const components: number[] = [];
|
|
183
|
+
for (const frame of ownFrames) {
|
|
184
|
+
const sample = frame.players.find((row) => row.playerIndex === playerIndex)!;
|
|
185
|
+
const group = positionGroupOf(pkg.match.mapName, side, sample.callout ?? "");
|
|
186
|
+
if (group) dwell.set(group, (dwell.get(group) ?? 0) + frameSeconds);
|
|
187
|
+
if (!sample.callout) unresolved += frameSeconds;
|
|
188
|
+
const values = distances(frame, playerIndex);
|
|
189
|
+
if (values.nearest != null) nearest.push(values.nearest);
|
|
190
|
+
if (values.centroid != null) centroid.push(values.centroid);
|
|
191
|
+
if (values.componentSize != null) components.push(values.componentSize);
|
|
192
|
+
}
|
|
193
|
+
const isolationSegments: PlayerPositionRoundFact["isolationSegments"] = [];
|
|
194
|
+
const rejoinTicks: number[] = [];
|
|
195
|
+
let startTick: number | null = null;
|
|
196
|
+
for (const frame of ownFrames) {
|
|
197
|
+
const isolated = distances(frame, playerIndex).componentSize === 1 && frame.players.length > 1;
|
|
198
|
+
if (isolated && startTick == null) startTick = frame.tick;
|
|
199
|
+
if (!isolated && startTick != null) {
|
|
200
|
+
const seconds = (frame.tick - startTick) / (pkg.match.tickrate || 64);
|
|
201
|
+
if (seconds >= 0.5) isolationSegments.push({ startTick, endTick: frame.tick, seconds: rounded(seconds)! });
|
|
202
|
+
rejoinTicks.push(frame.tick);
|
|
203
|
+
startTick = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (startTick != null && ownFrames.length > 0) {
|
|
207
|
+
const endTick = ownFrames.at(-1)!.tick + context.tickStep;
|
|
208
|
+
const seconds = (endTick - startTick) / (pkg.match.tickrate || 64);
|
|
209
|
+
if (seconds >= 0.5) isolationSegments.push({ startTick, endTick, seconds: rounded(seconds)! });
|
|
210
|
+
}
|
|
211
|
+
const eligibleSeconds = ownFrames.length * frameSeconds;
|
|
212
|
+
const openingEligibleSeconds = openingFrames.length * frameSeconds;
|
|
213
|
+
const openingDwell = new Map<string, number>();
|
|
214
|
+
const openingComponents: number[] = [];
|
|
215
|
+
let openingIsolatedFrames = 0;
|
|
216
|
+
for (const frame of openingFrames) {
|
|
217
|
+
const sample = frame.players.find((row) => row.playerIndex === playerIndex)!;
|
|
218
|
+
const group = positionGroupOf(pkg.match.mapName, side, sample.callout ?? "");
|
|
219
|
+
if (group) openingDwell.set(group, (openingDwell.get(group) ?? 0) + frameSeconds);
|
|
220
|
+
const componentSize = distances(frame, playerIndex).componentSize;
|
|
221
|
+
if (componentSize != null) openingComponents.push(componentSize);
|
|
222
|
+
if (componentSize === 1 && frame.players.length > 1) openingIsolatedFrames += 1;
|
|
223
|
+
}
|
|
224
|
+
const awp = extractAwpRoundFacts(pkg, context, track);
|
|
225
|
+
const pathStride = Math.max(1, Math.round(5 / frameSeconds));
|
|
226
|
+
const openingPath = openingFrames.filter((frame, index) => index === 0 || index === openingFrames.length - 1 || index % pathStride === 0).slice(0, 8).map((frame) => {
|
|
227
|
+
const sample = frame.players.find((row) => row.playerIndex === playerIndex)!;
|
|
228
|
+
return { tick: frame.tick, callout: sample.callout, positionGroupId: positionGroupOf(pkg.match.mapName, side, sample.callout ?? ""), ...sample.point };
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId, mapName: pkg.match.mapName, roundNumber: round.roundNumber,
|
|
232
|
+
teamKey: player.teamKey, side, playerIndex, steamId64: player.steamId64, economyType: economyType(player.teamKey),
|
|
233
|
+
openingWindow,
|
|
234
|
+
openingEligibleSeconds: rounded(openingEligibleSeconds),
|
|
235
|
+
openingPositionGroupDwell: [...openingDwell.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([positionGroupId, seconds]) => ({ positionGroupId, seconds: rounded(seconds)!, share: openingEligibleSeconds === 0 ? 0 : rounded(seconds / openingEligibleSeconds)! })),
|
|
236
|
+
openingMeanComponentSize: rounded(mean(openingComponents)),
|
|
237
|
+
openingIsolationSeconds: rounded(openingIsolatedFrames * frameSeconds),
|
|
238
|
+
...utilityCounts(playerIndex),
|
|
239
|
+
openingPath,
|
|
240
|
+
eligibleSeconds: rounded(eligibleSeconds),
|
|
241
|
+
positionGroupDwell: [...dwell.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([positionGroupId, seconds]) => ({ positionGroupId, seconds: rounded(seconds)!, share: eligibleSeconds === 0 ? 0 : rounded(seconds / eligibleSeconds)! })),
|
|
242
|
+
unresolvedCalloutSeconds: rounded(unresolved), calloutCoverage: eligibleSeconds === 0 ? null : rounded(1 - unresolved / eligibleSeconds),
|
|
243
|
+
meanNearestTeammateDistance: rounded(mean(nearest)), meanTeamCentroidDistance: rounded(mean(centroid)), meanComponentSize: rounded(mean(components)),
|
|
244
|
+
isolationSegments, rejoinTicks,
|
|
245
|
+
delayedConvergences: detectDelayedConvergences(ownFrames, playerIndex, pkg.match.tickrate || 64, context.tickStep),
|
|
246
|
+
movementSync: movementSync(ownFrames, playerIndex), ...awp,
|
|
247
|
+
availability: availability(context, track, grid, hasNav, Boolean(pkg.shots)),
|
|
248
|
+
} satisfies PlayerPositionRoundFact;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FLAG_ALIVE, type DemoPackage } from "@cs2dak/contract";
|
|
3
|
+
import { buildRoundSpatialFrames } from "./spatial.js";
|
|
4
|
+
import type { ReplayRoundContext, ReplayRoundTrack } from "../tactics/replay-round-context.js";
|
|
5
|
+
|
|
6
|
+
function track(playerIndex: number, x: number, y = 1, z = 1): ReplayRoundTrack {
|
|
7
|
+
return { playerIndex, teamKey: "teamA", side: "ct", steamId64: String(playerIndex), x: [x], y: [y], z: [z], flags: [FLAG_ALIVE], place: [0], weapon: [0] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("map-intelligence spatial frames", () => {
|
|
11
|
+
it("drops NaN and Infinity coordinates without discarding valid teammates", () => {
|
|
12
|
+
const round = { roundNumber: 1, freezeEndTick: 0, endTick: 64, teamASide: "ct", teamBSide: "t" } as DemoPackage["rounds"][number];
|
|
13
|
+
const context: ReplayRoundContext = { round, startTick: 0, tickStep: 32, frameCount: 1, placeDict: [], weaponDict: [], tracks: [track(0, 1), track(1, Number.NaN), track(2, 1, Number.POSITIVE_INFINITY)] };
|
|
14
|
+
const frames = buildRoundSpatialFrames(context, null, null);
|
|
15
|
+
expect(frames).toHaveLength(1);
|
|
16
|
+
expect(frames[0]?.players.map((player) => player.playerIndex)).toEqual([0]);
|
|
17
|
+
expect(frames[0]?.components).toEqual([[0]]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("emits no spatial frame when every alive coordinate is invalid", () => {
|
|
21
|
+
const round = { roundNumber: 1, freezeEndTick: 0, endTick: 64, teamASide: "ct", teamBSide: "t" } as DemoPackage["rounds"][number];
|
|
22
|
+
const context: ReplayRoundContext = { round, startTick: 0, tickStep: 32, frameCount: 1, placeDict: [], weaponDict: [], tracks: [track(0, Number.NaN), track(1, Number.NEGATIVE_INFINITY)] };
|
|
23
|
+
expect(buildRoundSpatialFrames(context, null, null)).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { FLAG_ALIVE, type TeamKey } from "@cs2dak/contract";
|
|
2
|
+
import { findNavPath, nearestNavArea, type CalloutGrid, type CompactNav, type Vec3 } from "@cs2dak/maps";
|
|
3
|
+
import { replayCalloutAt, replayPointAt, replayTickAt, type ReplayRoundContext, type ReplayRoundTrack } from "../tactics/replay-round-context.js";
|
|
4
|
+
|
|
5
|
+
export interface SpatialPlayerSample {
|
|
6
|
+
playerIndex: number;
|
|
7
|
+
track: ReplayRoundTrack;
|
|
8
|
+
point: Vec3;
|
|
9
|
+
callout: string | null;
|
|
10
|
+
navAreaId: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TeamSpatialFrame {
|
|
14
|
+
tick: number;
|
|
15
|
+
teamKey: TeamKey;
|
|
16
|
+
side: "t" | "ct";
|
|
17
|
+
players: SpatialPlayerSample[];
|
|
18
|
+
components: number[][];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const COMPONENT_DISTANCE = 800;
|
|
22
|
+
const SAME_CALLOUT_DISTANCE = 1200;
|
|
23
|
+
const MAX_NAV_HOPS = 10;
|
|
24
|
+
const navConnectivityCache = new WeakMap<CompactNav, Map<string, boolean>>();
|
|
25
|
+
|
|
26
|
+
function distance(a: Vec3, b: Vec3): number {
|
|
27
|
+
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function finitePoint(point: Vec3): boolean {
|
|
31
|
+
return Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function navConnected(nav: CompactNav, first: number, second: number): boolean {
|
|
35
|
+
const key = first < second ? `${first}:${second}` : `${second}:${first}`;
|
|
36
|
+
const cache = navConnectivityCache.get(nav) ?? new Map<string, boolean>();
|
|
37
|
+
if (!navConnectivityCache.has(nav)) navConnectivityCache.set(nav, cache);
|
|
38
|
+
const cached = cache.get(key);
|
|
39
|
+
if (cached != null) return cached;
|
|
40
|
+
const forward = findNavPath(nav, first, second);
|
|
41
|
+
const reverse = forward.length === 0 ? findNavPath(nav, second, first) : forward;
|
|
42
|
+
const connected = reverse.length > 0 && reverse.length <= MAX_NAV_HOPS;
|
|
43
|
+
cache.set(key, connected);
|
|
44
|
+
return connected;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function areConnected(first: SpatialPlayerSample, second: SpatialPlayerSample, nav: CompactNav | null): boolean {
|
|
48
|
+
const straight = distance(first.point, second.point);
|
|
49
|
+
if (straight > COMPONENT_DISTANCE && !(first.callout && first.callout === second.callout && straight <= SAME_CALLOUT_DISTANCE)) return false;
|
|
50
|
+
if (!nav || first.navAreaId == null || second.navAreaId == null) return true;
|
|
51
|
+
return navConnected(nav, first.navAreaId, second.navAreaId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function components(players: readonly SpatialPlayerSample[], nav: CompactNav | null): number[][] {
|
|
55
|
+
const remaining = new Set(players.map((player) => player.playerIndex));
|
|
56
|
+
const byIndex = new Map(players.map((player) => [player.playerIndex, player]));
|
|
57
|
+
const groups: number[][] = [];
|
|
58
|
+
while (remaining.size > 0) {
|
|
59
|
+
const first = remaining.values().next().value as number;
|
|
60
|
+
remaining.delete(first);
|
|
61
|
+
const group = [first];
|
|
62
|
+
for (let cursor = 0; cursor < group.length; cursor += 1) {
|
|
63
|
+
const current = byIndex.get(group[cursor]!)!;
|
|
64
|
+
for (const candidate of [...remaining]) {
|
|
65
|
+
if (!areConnected(current, byIndex.get(candidate)!, nav)) continue;
|
|
66
|
+
remaining.delete(candidate);
|
|
67
|
+
group.push(candidate);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
groups.push(group.sort((a, b) => a - b));
|
|
71
|
+
}
|
|
72
|
+
return groups.sort((a, b) => b.length - a.length || a[0]! - b[0]!);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Builds only the current round's short-lived spatial samples; never persist this structure. */
|
|
76
|
+
export function buildRoundSpatialFrames(
|
|
77
|
+
context: ReplayRoundContext,
|
|
78
|
+
grid: CalloutGrid | null,
|
|
79
|
+
nav: CompactNav | null,
|
|
80
|
+
): TeamSpatialFrame[] {
|
|
81
|
+
const frames: TeamSpatialFrame[] = [];
|
|
82
|
+
for (let frameIndex = 0; frameIndex < context.frameCount; frameIndex += 1) {
|
|
83
|
+
const tick = replayTickAt(context, frameIndex);
|
|
84
|
+
if (tick < context.round.freezeEndTick || tick > context.round.endTick) continue;
|
|
85
|
+
for (const side of ["t", "ct"] as const) {
|
|
86
|
+
const teamKey: TeamKey = context.round.teamASide === side ? "teamA" : "teamB";
|
|
87
|
+
const players = context.tracks
|
|
88
|
+
.filter((track) => track.side === side && ((track.flags[frameIndex] ?? 0) & FLAG_ALIVE) !== 0)
|
|
89
|
+
.map((track) => {
|
|
90
|
+
const point = replayPointAt(track, frameIndex);
|
|
91
|
+
if (!finitePoint(point)) return null;
|
|
92
|
+
return {
|
|
93
|
+
playerIndex: track.playerIndex,
|
|
94
|
+
track,
|
|
95
|
+
point,
|
|
96
|
+
callout: replayCalloutAt(context, track, frameIndex, grid),
|
|
97
|
+
navAreaId: nav ? nearestNavArea(nav, point)?.id ?? null : null,
|
|
98
|
+
};
|
|
99
|
+
}).filter((player): player is SpatialPlayerSample => player != null);
|
|
100
|
+
if (players.length > 0) frames.push({ tick, teamKey, side, players, components: components(players, nav) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return frames;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function mean(values: readonly number[]): number | null {
|
|
107
|
+
return values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function rounded(value: number | null, digits = 3): number | null {
|
|
111
|
+
return value == null ? null : Math.round(value * 10 ** digits) / 10 ** digits;
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { FLAG_ALIVE, MAP_INTELLIGENCE_FACT_VERSION, type DemoPackage, type PlayerPositionRoundFact, type TeamAwpRoundFact } from "@cs2dak/contract";
|
|
2
|
+
import { replayWeaponAt, type ReplayRoundContext } from "../tactics/replay-round-context.js";
|
|
3
|
+
import { rounded } from "./spatial.js";
|
|
4
|
+
|
|
5
|
+
function isAwp(value: string | null): boolean { return value?.trim().toLowerCase().replace(/^weapon_/, "") === "awp"; }
|
|
6
|
+
|
|
7
|
+
function awpDamageForTeam(pkg: DemoPackage, round: DemoPackage["rounds"][number], teamKey: "teamA" | "teamB"): number | null {
|
|
8
|
+
// A valid v3 manifest always names damages.json. Keep null only for a genuinely
|
|
9
|
+
// unavailable package rather than turning an unknown fact into zero.
|
|
10
|
+
if (!pkg.manifest?.files?.damages) return null;
|
|
11
|
+
return pkg.damages
|
|
12
|
+
.filter((damage) => damage.roundNumber === round.roundNumber && damage.tick >= round.freezeEndTick && damage.tick <= round.endTick)
|
|
13
|
+
.filter((damage) => damage.attackerIndex != null && pkg.players[damage.attackerIndex]?.teamKey === teamKey)
|
|
14
|
+
.filter((damage) => pkg.players[damage.victimIndex]?.teamKey !== teamKey)
|
|
15
|
+
.filter((damage) => isAwp(damage.weapon))
|
|
16
|
+
.reduce((sum, damage) => sum + damage.healthDamage, 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function phase(roundNumber: number): TeamAwpRoundFact["scorePhase"] {
|
|
20
|
+
return roundNumber <= 12 ? "first_half" : roundNumber <= 24 ? "second_half" : "overtime";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractTeamAwpRoundFacts(pkg: DemoPackage, matchId: string, context: ReplayRoundContext | null, rows: PlayerPositionRoundFact[]): TeamAwpRoundFact[] {
|
|
24
|
+
const round = context?.round ?? pkg.rounds.find((candidate) => candidate.roundNumber === rows[0]?.roundNumber);
|
|
25
|
+
if (!round) return [];
|
|
26
|
+
const firstKill = [...pkg.kills].filter((kill) => kill.roundNumber === round.roundNumber).sort((a, b) => a.tick - b.tick)[0] ?? null;
|
|
27
|
+
const frameSeconds = context ? context.tickStep / (pkg.match.tickrate || 64) : 0;
|
|
28
|
+
return (["teamA", "teamB"] as const).map((teamKey) => {
|
|
29
|
+
const side = teamKey === "teamA" ? round.teamASide : round.teamBSide;
|
|
30
|
+
const teamRows = rows.filter((row) => row.teamKey === teamKey);
|
|
31
|
+
const roundStartAwpPlayerIndices = teamRows.filter((row) => row.freezeAwpOwnership).map((row) => row.playerIndex).sort((a, b) => a - b);
|
|
32
|
+
let doubleFrames = 0;
|
|
33
|
+
if (context) for (let frameIndex = 0; frameIndex < context.frameCount; frameIndex += 1) {
|
|
34
|
+
const tick = context.startTick + frameIndex * context.tickStep;
|
|
35
|
+
if (tick < round.freezeEndTick || tick > round.endTick) continue;
|
|
36
|
+
const active = context.tracks.filter((track) => track.teamKey === teamKey
|
|
37
|
+
&& ((track.flags[frameIndex] ?? 0) & FLAG_ALIVE) !== 0
|
|
38
|
+
&& [track.x[frameIndex], track.y[frameIndex], track.z[frameIndex]].every((value) => value != null && Number.isFinite(value))
|
|
39
|
+
&& isAwp(replayWeaponAt(context, track, frameIndex))).length;
|
|
40
|
+
if (active >= 2) doubleFrames += 1;
|
|
41
|
+
}
|
|
42
|
+
const won = round.winnerTeamKey === teamKey;
|
|
43
|
+
const firstKillerTeam = firstKill?.killerIndex == null ? null : pkg.players[firstKill.killerIndex]?.teamKey ?? null;
|
|
44
|
+
const firstVictimTeam = pkg.players[firstKill?.victimIndex ?? -1]?.teamKey ?? null;
|
|
45
|
+
const available = teamRows.some((row) => row.activeAwpSeconds != null);
|
|
46
|
+
return {
|
|
47
|
+
analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId, mapName: pkg.match.mapName, roundNumber: round.roundNumber, teamKey, side,
|
|
48
|
+
economyType: teamKey === "teamA" ? round.teamAEconomy : round.teamBEconomy,
|
|
49
|
+
opponentEconomyType: teamKey === "teamA" ? round.teamBEconomy : round.teamAEconomy,
|
|
50
|
+
scorePhase: phase(round.roundNumber), won, roundStartAwpPlayerIndices,
|
|
51
|
+
doubleAwpActiveSeconds: context ? rounded(doubleFrames * frameSeconds) : null,
|
|
52
|
+
awpActiveSeconds: available ? rounded(teamRows.reduce((sum, row) => sum + (row.activeAwpSeconds ?? 0), 0)) : null,
|
|
53
|
+
awpShots: teamRows.some((row) => row.awpShots != null) ? teamRows.reduce((sum, row) => sum + (row.awpShots ?? 0), 0) : null,
|
|
54
|
+
awpKills: teamRows.some((row) => row.awpKills != null) ? teamRows.reduce((sum, row) => sum + (row.awpKills ?? 0), 0) : null,
|
|
55
|
+
awpDamage: awpDamageForTeam(pkg, round, teamKey),
|
|
56
|
+
openingKills: firstKillerTeam === teamKey ? 1 : 0, openingDeaths: firstVictimTeam === teamKey ? 1 : 0,
|
|
57
|
+
availability: teamRows[0]?.availability ?? { replay: "missing", nav: "missing", callouts: "missing", shots: "missing" },
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ReplayRoundContext } from "../tactics/replay-round-context.js";
|
|
3
|
+
import type { TeamSpatialFrame } from "./spatial.js";
|
|
4
|
+
import { extractTeamShapeRoundFacts } from "./team-shape.js";
|
|
5
|
+
|
|
6
|
+
const round = { roundNumber: 1, freezeEndTick: 0, endTick: 3000, teamASide: "t", teamBSide: "ct" } as const;
|
|
7
|
+
const context = { round, startTick: 0, tickStep: 64, frameCount: 48, placeDict: [], weaponDict: [], tracks: [] } as unknown as ReplayRoundContext;
|
|
8
|
+
|
|
9
|
+
function frame(tick: number, components: number[][]): TeamSpatialFrame {
|
|
10
|
+
const indices = components.flat();
|
|
11
|
+
return {
|
|
12
|
+
tick, teamKey: "teamA", side: "t", components,
|
|
13
|
+
players: indices.map((playerIndex) => ({ playerIndex, track: {} as never, point: { x: playerIndex, y: 0, z: 0 }, callout: null, navAreaId: null })),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("team-shape compact windows", () => {
|
|
18
|
+
it.each([
|
|
19
|
+
[[[0, 1, 2, 3, 4]]],
|
|
20
|
+
[[[0, 1, 2, 3], [4]]],
|
|
21
|
+
[[[0, 1, 2], [3, 4]]],
|
|
22
|
+
[[[0, 1, 2], [3], [4]]],
|
|
23
|
+
[[[0, 1], [2, 3], [4]]],
|
|
24
|
+
])("retains the %s partition with exact component membership", (components) => {
|
|
25
|
+
const result = extractTeamShapeRoundFacts(
|
|
26
|
+
{ match: { mapName: "de_ancient", tickrate: 64 } }, "m1", context, round,
|
|
27
|
+
[frame(0, components), frame(64, components)], null, false,
|
|
28
|
+
)[0]!;
|
|
29
|
+
expect(result.windows[0]?.partition).toBe(components.map((group) => group.length).sort((a, b) => b - a).join("+"));
|
|
30
|
+
expect(result.windows[0]?.componentPlayerIndices).toEqual(components);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps late rotation in full-round windows but outside opening responsibility", () => {
|
|
34
|
+
const opening = [[0, 1, 2, 3], [4]];
|
|
35
|
+
const late = [[0, 1, 2], [3, 4]];
|
|
36
|
+
const result = extractTeamShapeRoundFacts(
|
|
37
|
+
{ match: { mapName: "de_ancient", tickrate: 64 } }, "m1", context, round,
|
|
38
|
+
[frame(0, opening), frame(64, opening), frame(1408, late), frame(1472, late)], null, false,
|
|
39
|
+
)[0]!;
|
|
40
|
+
expect(result.openingWindows.map((window) => window.partition)).toEqual(["4+1"]);
|
|
41
|
+
expect(result.windows.map((window) => window.partition)).toEqual(["4+1", "3+2"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { MAP_INTELLIGENCE_FACT_VERSION, type MapIntelligenceAvailability, type TeamShapeRoundFact } from "@cs2dak/contract";
|
|
2
|
+
import type { CalloutGrid } from "@cs2dak/maps";
|
|
3
|
+
import type { ReplayRoundContext } from "../tactics/replay-round-context.js";
|
|
4
|
+
import { rounded, type TeamSpatialFrame } from "./spatial.js";
|
|
5
|
+
import { openingResponsibilityWindow } from "./opening-window.js";
|
|
6
|
+
|
|
7
|
+
function availability(context: ReplayRoundContext | null, grid: CalloutGrid | null, hasNav: boolean, hasShots: boolean): MapIntelligenceAvailability {
|
|
8
|
+
return { replay: context ? "available" : "missing", nav: hasNav ? "available" : "missing", callouts: context && (context.placeDict.length > 0 || grid) ? "available" : context ? "degraded" : "missing", shots: hasShots ? "available" : "missing" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function signature(frame: TeamSpatialFrame): string {
|
|
12
|
+
return frame.components.map((component) => component.join(",")).join("|");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Debounce one-frame component flicker while retaining exact component membership in persisted windows. */
|
|
16
|
+
function smoothed(frames: readonly TeamSpatialFrame[]): TeamSpatialFrame[] {
|
|
17
|
+
return frames.map((frame, index) => {
|
|
18
|
+
const previous = frames[index - 1];
|
|
19
|
+
const next = frames[index + 1];
|
|
20
|
+
return previous && next && signature(previous) === signature(next) ? { ...frame, components: previous.components } : frame;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function extractTeamShapeRoundFacts(
|
|
25
|
+
pkg: { match: { mapName: string; tickrate: number }; shots?: unknown },
|
|
26
|
+
matchId: string,
|
|
27
|
+
context: ReplayRoundContext | null,
|
|
28
|
+
round: { roundNumber: number; teamASide: "t" | "ct"; teamBSide: "t" | "ct" },
|
|
29
|
+
frames: readonly TeamSpatialFrame[],
|
|
30
|
+
grid: CalloutGrid | null,
|
|
31
|
+
hasNav: boolean,
|
|
32
|
+
): TeamShapeRoundFact[] {
|
|
33
|
+
const frameSeconds = context ? context.tickStep / (pkg.match.tickrate || 64) : 0;
|
|
34
|
+
const compactWindows = (teamFrames: readonly TeamSpatialFrame[]): TeamShapeRoundFact["windows"] => {
|
|
35
|
+
const windows: TeamShapeRoundFact["windows"] = [];
|
|
36
|
+
for (let start = 0; start < teamFrames.length;) {
|
|
37
|
+
const first = teamFrames[start]!;
|
|
38
|
+
const key = signature(first);
|
|
39
|
+
let end = start + 1;
|
|
40
|
+
while (end < teamFrames.length && signature(teamFrames[end]!) === key) end += 1;
|
|
41
|
+
const last = teamFrames[end - 1]!;
|
|
42
|
+
const componentPlayerIndices = first.components.map((component) => [...component]);
|
|
43
|
+
const componentSizes = componentPlayerIndices.map((component) => component.length).sort((a, b) => b - a);
|
|
44
|
+
windows.push({ startTick: first.tick, endTick: last.tick + context!.tickStep, coverageSeconds: rounded((last.tick + context!.tickStep - first.tick) / (pkg.match.tickrate || 64))!, componentSizes, partition: componentSizes.join("+"), componentPlayerIndices });
|
|
45
|
+
start = end;
|
|
46
|
+
}
|
|
47
|
+
return windows;
|
|
48
|
+
};
|
|
49
|
+
return (["teamA", "teamB"] as const).map((teamKey) => {
|
|
50
|
+
const teamFrames = smoothed(frames.filter((frame) => frame.teamKey === teamKey));
|
|
51
|
+
const window = context ? openingResponsibilityWindow(context.round, pkg.match.tickrate || 64) : null;
|
|
52
|
+
const openingFrames = window ? teamFrames.filter((frame) => frame.tick >= window.startTick && frame.tick < window.endTick) : [];
|
|
53
|
+
const windows = compactWindows(teamFrames);
|
|
54
|
+
const openingWindows = compactWindows(openingFrames);
|
|
55
|
+
const side = teamKey === "teamA" ? round.teamASide : round.teamBSide;
|
|
56
|
+
return { analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId, mapName: pkg.match.mapName, roundNumber: round.roundNumber, teamKey, side, openingWindow: window, openingWindows, coverageSeconds: teamFrames.length ? rounded(teamFrames.length * frameSeconds) : null, windows, availability: availability(context, grid, hasNav, Boolean(pkg.shots)) } satisfies TeamShapeRoundFact;
|
|
57
|
+
});
|
|
58
|
+
}
|