@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.
Files changed (67) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +4 -3
  3. package/src/duel-window.ts +199 -0
  4. package/src/duels.test.ts +370 -0
  5. package/src/duels.ts +538 -0
  6. package/src/fixture-invariants.test.ts +40 -0
  7. package/src/index.test.ts +68 -88
  8. package/src/index.ts +41 -9
  9. package/src/loader.ts +31 -17
  10. package/src/map-intelligence/awp.test.ts +57 -0
  11. package/src/map-intelligence/awp.ts +45 -0
  12. package/src/map-intelligence/ct-rotation.test.ts +149 -0
  13. package/src/map-intelligence/ct-rotation.ts +324 -0
  14. package/src/map-intelligence/index.ts +74 -0
  15. package/src/map-intelligence/map-intelligence.test.ts +62 -0
  16. package/src/map-intelligence/opening-window.ts +19 -0
  17. package/src/map-intelligence/player-position.test.ts +28 -0
  18. package/src/map-intelligence/player-position.ts +250 -0
  19. package/src/map-intelligence/spatial.test.ts +25 -0
  20. package/src/map-intelligence/spatial.ts +112 -0
  21. package/src/map-intelligence/team-awp-round.ts +60 -0
  22. package/src/map-intelligence/team-shape.test.ts +43 -0
  23. package/src/map-intelligence/team-shape.ts +58 -0
  24. package/src/mechanics.test.ts +375 -0
  25. package/src/mechanics.ts +628 -0
  26. package/src/normalize.ts +26 -149
  27. package/src/qa.test.ts +115 -0
  28. package/src/qa.ts +51 -15
  29. package/src/radar-field.test.ts +79 -0
  30. package/src/radar-field.ts +395 -0
  31. package/src/resolve.test.ts +100 -0
  32. package/src/resolve.ts +69 -0
  33. package/src/scoreboard.ts +68 -38
  34. package/src/side-win-rate.test.ts +33 -0
  35. package/src/side-win-rate.ts +49 -0
  36. package/src/signals.ts +150 -113
  37. package/src/spatial/annotate.test.ts +133 -0
  38. package/src/spatial/annotate.ts +163 -0
  39. package/src/spatial/index.ts +16 -0
  40. package/src/spatial/mapcontrol.test.ts +131 -0
  41. package/src/spatial/mapcontrol.ts +277 -0
  42. package/src/spatial/phase.test.ts +171 -0
  43. package/src/spatial/phase.ts +179 -0
  44. package/src/spatial/trade-closure.test.ts +38 -0
  45. package/src/spatial/types.ts +56 -0
  46. package/src/spatial/utility-geometry.test.ts +88 -0
  47. package/src/spatial/utility-geometry.ts +167 -0
  48. package/src/spatial/utility.integration.test.ts +38 -0
  49. package/src/spatial/utility.test.ts +120 -0
  50. package/src/spatial/utility.ts +399 -0
  51. package/src/tactics/formations.ts +151 -0
  52. package/src/tactics/index.ts +16 -0
  53. package/src/tactics/replay-round-context.ts +96 -0
  54. package/src/tactics/round-facts.ts +406 -0
  55. package/src/tactics/segments.ts +68 -0
  56. package/src/tactics/tactics.test.ts +112 -0
  57. package/src/tactics/types.ts +73 -0
  58. package/src/timeline.ts +73 -52
  59. package/src/utility-facts.test.ts +55 -0
  60. package/src/utility-facts.ts +137 -0
  61. package/src/utils.ts +27 -25
  62. package/src/weapon-highlights.ts +55 -0
  63. package/src/economy.test.ts +0 -51
  64. package/src/economy.ts +0 -76
  65. package/src/weapons.test.ts +0 -32
  66. package/src/weapons.ts +0 -93
  67. package/src/workspace.ts +0 -726
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FLAG_ALIVE, type DemoPackage } from "@cs2dak/contract";
3
+ import type { ReplayRoundContext, ReplayRoundTrack } from "../tactics/replay-round-context.js";
4
+ import { extractCtRotationRoundFacts } from "./ct-rotation.js";
5
+ import { buildRoundSpatialFrames } from "./spatial.js";
6
+
7
+ const frameCount = 71;
8
+ const placeDict = ["BombsiteA", "CTSpawn", "BombsiteB", "TRamp", "Apartments"];
9
+ const round = {
10
+ roundNumber: 1,
11
+ freezeEndTick: 100,
12
+ endTick: 800,
13
+ teamASide: "ct",
14
+ teamBSide: "t",
15
+ } as DemoPackage["rounds"][number];
16
+
17
+ function track(
18
+ playerIndex: number,
19
+ teamKey: "teamA" | "teamB",
20
+ side: "ct" | "t",
21
+ places: number[],
22
+ flags = Array(frameCount).fill(FLAG_ALIVE),
23
+ ): ReplayRoundTrack {
24
+ return {
25
+ playerIndex,
26
+ teamKey,
27
+ side,
28
+ steamId64: `7656119800000000${playerIndex + 1}`,
29
+ x: Array(frameCount).fill(playerIndex * 100),
30
+ y: Array(frameCount).fill(0),
31
+ z: Array(frameCount).fill(0),
32
+ flags,
33
+ place: places,
34
+ weapon: Array(frameCount).fill(-1),
35
+ };
36
+ }
37
+
38
+ function scenario(options: { deathTick?: number; otherContactTick?: number; secondRotates?: boolean; deathWithoutEnemyContact?: boolean } = {}) {
39
+ const deathTick = options.deathTick ?? null;
40
+ const otherContactTick = options.otherContactTick ?? 305;
41
+ const rotatingFlags = Array(frameCount).fill(FLAG_ALIVE);
42
+ if (deathTick != null) rotatingFlags.fill(0, Math.floor((deathTick - 100) / 10) + 1);
43
+ const rotatingPlaces = Array.from({ length: frameCount }, (_, index) => index <= 20 ? 0 : index < 30 ? 1 : 2);
44
+ const otherContactFrame = Math.round((otherContactTick - 100) / 10);
45
+ const context: ReplayRoundContext = {
46
+ round,
47
+ startTick: 100,
48
+ tickStep: 10,
49
+ frameCount,
50
+ placeDict,
51
+ weaponDict: [],
52
+ tracks: [
53
+ track(0, "teamA", "ct", rotatingPlaces, rotatingFlags),
54
+ track(1, "teamA", "ct", options.secondRotates ? rotatingPlaces : Array(frameCount).fill(0)),
55
+ track(2, "teamB", "t", Array.from({ length: frameCount }, (_, index) => index < otherContactFrame ? 3 : 4)),
56
+ ],
57
+ };
58
+ const pkg = {
59
+ match: { mapName: "de_mirage", tickrate: 10 },
60
+ rounds: [round],
61
+ players: [
62
+ { steamId64: "76561198000000001", teamKey: "teamA" },
63
+ { steamId64: "76561198000000002", teamKey: "teamA" },
64
+ { steamId64: "76561198000000003", teamKey: "teamB" },
65
+ ],
66
+ damages: [
67
+ { roundNumber: 1, tick: 250, attackerIndex: 2, victimIndex: 0, healthDamageRaw: 10 },
68
+ { roundNumber: 1, tick: otherContactTick, attackerIndex: 2, victimIndex: 0, healthDamageRaw: 10 },
69
+ ],
70
+ kills: deathTick == null ? [] : [{ roundNumber: 1, tick: deathTick, killerIndex: options.deathWithoutEnemyContact ? null : 2, victimIndex: 0 }],
71
+ shots: undefined,
72
+ } as unknown as DemoPackage;
73
+ const frames = buildRoundSpatialFrames(context, null, null);
74
+ return extractCtRotationRoundFacts(pkg, "m1", context, round, frames, null, false);
75
+ }
76
+
77
+ describe("extractCtRotationRoundFacts", () => {
78
+ it("records a stable cross-area response and its team-relative order without emitting a role", () => {
79
+ const row = scenario().find((candidate) => candidate.playerIndex === 0)!;
80
+
81
+ expect(row).toMatchObject({
82
+ initialPositionGroupId: "a_site",
83
+ initialRegion: "a",
84
+ initialPositionGroupShare: 1,
85
+ initialResponsibilityResolved: true,
86
+ firstOwnAreaContactTick: 250,
87
+ firstOtherAreaContactTick: 305,
88
+ firstTeamContactTick: 250,
89
+ leftInitialPositionGroupTick: 310,
90
+ leaveDelayAfterFirstOtherAreaContactSeconds: 0.5,
91
+ firstStableDestinationPositionGroupId: "b_site",
92
+ firstStableDestinationRegion: "b",
93
+ crossedResponsibilityArea: true,
94
+ returnedToInitialPositionGroup: false,
95
+ transitToStableDestinationSeconds: 9,
96
+ crossAreaDepartureOrder: 1,
97
+ firstCrossAreaDeparture: true,
98
+ priorCrossAreaDeparturesAlive: 0,
99
+ initialAreaStillCovered: true,
100
+ censoredByDeath: false,
101
+ });
102
+ expect(row).not.toHaveProperty("role");
103
+ });
104
+
105
+ it("keeps an unfinished response unknown when death truncates observation", () => {
106
+ const row = scenario({ deathTick: 330 }).find((candidate) => candidate.playerIndex === 0)!;
107
+
108
+ expect(row.deathTick).toBe(330);
109
+ expect(row.censoredByDeath).toBe(true);
110
+ expect(row.leftInitialPositionGroupTick).toBeNull();
111
+ expect(row.firstStableDestinationPositionGroupId).toBeNull();
112
+ expect(row.crossedResponsibilityArea).toBeNull();
113
+ expect(row.crossAreaDepartureOrder).toBeNull();
114
+ });
115
+
116
+ it("records an observed full-round stay as false rather than unknown", () => {
117
+ const row = scenario().find((candidate) => candidate.playerIndex === 1)!;
118
+
119
+ expect(row.initialResponsibilityResolved).toBe(true);
120
+ expect(row.deathTick).toBeNull();
121
+ expect(row.censoredByDeath).toBe(false);
122
+ expect(row.crossedResponsibilityArea).toBe(false);
123
+ });
124
+
125
+ it("uses signed contact delay instead of hiding a departure that came first", () => {
126
+ const row = scenario({ otherContactTick: 350 }).find((candidate) => candidate.playerIndex === 0)!;
127
+
128
+ expect(row.leftInitialPositionGroupTick).toBe(310);
129
+ expect(row.firstOtherAreaContactTick).toBe(350);
130
+ expect(row.leaveDelayAfterFirstOtherAreaContactSeconds).toBe(-4);
131
+ });
132
+
133
+ it("clips team contacts to the player's observable lifetime", () => {
134
+ const row = scenario({ deathTick: 190, deathWithoutEnemyContact: true }).find((candidate) => candidate.playerIndex === 0)!;
135
+
136
+ expect(row.censoredByDeath).toBe(true);
137
+ expect(row.firstTeamContactTick).toBeNull();
138
+ expect(row.firstOwnAreaContactTick).toBeNull();
139
+ expect(row.firstOtherAreaContactTick).toBeNull();
140
+ });
141
+
142
+ it("preserves simultaneous departures as deterministic shared ranks", () => {
143
+ const rows = scenario({ secondRotates: true }).filter((candidate) => candidate.playerIndex < 2);
144
+
145
+ expect(rows.map((row) => row.crossAreaDepartureOrder)).toEqual([1, 1]);
146
+ expect(rows.map((row) => row.firstCrossAreaDeparture)).toEqual([true, true]);
147
+ expect(rows.map((row) => row.priorCrossAreaDeparturesAlive)).toEqual([0, 0]);
148
+ });
149
+ });
@@ -0,0 +1,324 @@
1
+ import {
2
+ CT_ROTATION_FACT_VERSION,
3
+ MAP_INTELLIGENCE_FACT_VERSION,
4
+ type CtRotationAvailability,
5
+ type CtRotationRoundFact,
6
+ type DemoPackage,
7
+ } from "@cs2dak/contract";
8
+ import { getPrimaryCalloutRegion, positionGroupOf, type CalloutGrid, type TacticalRegion } from "@cs2dak/maps";
9
+ import {
10
+ replayCalloutAt,
11
+ type ReplayRoundContext,
12
+ type ReplayRoundTrack,
13
+ } from "../tactics/replay-round-context.js";
14
+ import { openingResponsibilityWindow } from "./opening-window.js";
15
+ import { rounded, type SpatialPlayerSample, type TeamSpatialFrame } from "./spatial.js";
16
+
17
+ export const CT_ROTATION_RESPONSE_PERSISTENCE_SECONDS = 2;
18
+
19
+ interface ContactEvent {
20
+ tick: number;
21
+ region: TacticalRegion;
22
+ }
23
+
24
+ interface ResponseObservation {
25
+ leftTick: number;
26
+ targetGroup: string;
27
+ targetRegion: TacticalRegion | null;
28
+ pathSeconds: number;
29
+ initialAreaStillCovered: boolean | null;
30
+ returned: boolean | null;
31
+ resolvedTick: number;
32
+ }
33
+
34
+ function availability(
35
+ pkg: DemoPackage,
36
+ context: ReplayRoundContext | null,
37
+ track: ReplayRoundTrack | null,
38
+ grid: CalloutGrid | null,
39
+ hasNav: boolean,
40
+ ): CtRotationAvailability {
41
+ const replay = context && track ? "available" : context ? "degraded" : "missing";
42
+ const combatSources = Number(Boolean(pkg.manifest?.files?.damages)) + Number(Boolean(pkg.manifest?.files?.kills));
43
+ return {
44
+ replay,
45
+ nav: hasNav ? "available" : "missing",
46
+ callouts: context && (context.placeDict.length > 0 || grid) ? "available" : context ? "degraded" : "missing",
47
+ shots: pkg.shots ? "available" : "missing",
48
+ combatTimeline: combatSources === 2 ? "available" : combatSources === 1 ? "degraded" : "missing",
49
+ };
50
+ }
51
+
52
+ function frameIndexForTick(context: ReplayRoundContext, tick: number): number {
53
+ return Math.max(0, Math.min(context.frameCount - 1, Math.round((tick - context.startTick) / context.tickStep)));
54
+ }
55
+
56
+ function sideOf(pkg: DemoPackage, round: DemoPackage["rounds"][number], playerIndex: number): "t" | "ct" | null {
57
+ const teamKey = pkg.players[playerIndex]?.teamKey;
58
+ return teamKey === "teamA" ? round.teamASide : teamKey === "teamB" ? round.teamBSide : null;
59
+ }
60
+
61
+ function regionAt(
62
+ pkg: DemoPackage,
63
+ context: ReplayRoundContext,
64
+ grid: CalloutGrid | null,
65
+ playerIndex: number,
66
+ tick: number,
67
+ ): TacticalRegion | null {
68
+ const track = context.tracks.find((row) => row.playerIndex === playerIndex);
69
+ if (!track) return null;
70
+ const callout = replayCalloutAt(context, track, frameIndexForTick(context, tick), grid);
71
+ return callout ? getPrimaryCalloutRegion(pkg.match.mapName, callout) : null;
72
+ }
73
+
74
+ function contactEvents(
75
+ pkg: DemoPackage,
76
+ context: ReplayRoundContext,
77
+ round: DemoPackage["rounds"][number],
78
+ grid: CalloutGrid | null,
79
+ ): ContactEvent[] {
80
+ const result: ContactEvent[] = [];
81
+ const add = (tick: number, first: number | null, second: number) => {
82
+ if (first == null || pkg.players[first]?.teamKey === pkg.players[second]?.teamKey) return;
83
+ const tPlayer = sideOf(pkg, round, first) === "t" ? first : sideOf(pkg, round, second) === "t" ? second : null;
84
+ const fallback = sideOf(pkg, round, first) === "ct" ? first : second;
85
+ const region = tPlayer == null ? null : regionAt(pkg, context, grid, tPlayer, tick) ?? regionAt(pkg, context, grid, fallback, tick);
86
+ if (region) result.push({ tick, region });
87
+ };
88
+ for (const damage of pkg.damages) {
89
+ if (damage.roundNumber === round.roundNumber && damage.healthDamageRaw > 0) add(damage.tick, damage.attackerIndex, damage.victimIndex);
90
+ }
91
+ for (const kill of pkg.kills) {
92
+ if (kill.roundNumber === round.roundNumber) add(kill.tick, kill.killerIndex, kill.victimIndex);
93
+ }
94
+ return result.sort((a, b) => a.tick - b.tick);
95
+ }
96
+
97
+ function groupAndRegion(mapName: string, sample: SpatialPlayerSample): { group: string | null; region: TacticalRegion | null } {
98
+ return {
99
+ group: sample.callout ? positionGroupOf(mapName, "ct", sample.callout) : null,
100
+ region: sample.callout ? getPrimaryCalloutRegion(mapName, sample.callout) : null,
101
+ };
102
+ }
103
+
104
+ function stableGroupAt(
105
+ frames: readonly TeamSpatialFrame[],
106
+ startIndex: number,
107
+ playerIndex: number,
108
+ mapName: string,
109
+ frameSeconds: number,
110
+ ): { group: string; region: TacticalRegion | null; resolvedTick: number } | null {
111
+ const firstSample = frames[startIndex]?.players.find((row) => row.playerIndex === playerIndex);
112
+ if (!firstSample) return null;
113
+ const first = groupAndRegion(mapName, firstSample);
114
+ if (!first.group) return null;
115
+ const group = first.group;
116
+ let seconds = 0;
117
+ let resolvedTick = frames[startIndex]!.tick;
118
+ for (let index = startIndex; index < frames.length; index += 1) {
119
+ const sample = frames[index]!.players.find((row) => row.playerIndex === playerIndex);
120
+ if (!sample || groupAndRegion(mapName, sample).group !== group) break;
121
+ seconds += frameSeconds;
122
+ resolvedTick = frames[index]!.tick;
123
+ if (seconds >= CT_ROTATION_RESPONSE_PERSISTENCE_SECONDS) return { group, region: first.region, resolvedTick };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function areaCovered(
129
+ frame: TeamSpatialFrame | undefined,
130
+ playerIndex: number,
131
+ mapName: string,
132
+ initialRegion: TacticalRegion | null,
133
+ ): boolean | null {
134
+ if (!frame || !initialRegion) return null;
135
+ const resolved = frame.players
136
+ .filter((row) => row.playerIndex !== playerIndex)
137
+ .map((row) => groupAndRegion(mapName, row).region)
138
+ .filter((region): region is TacticalRegion => region != null);
139
+ return resolved.includes(initialRegion);
140
+ }
141
+
142
+ function responseObservation(
143
+ frames: readonly TeamSpatialFrame[],
144
+ playerIndex: number,
145
+ mapName: string,
146
+ initialGroup: string,
147
+ initialRegion: TacticalRegion | null,
148
+ openingEndTick: number,
149
+ deathTick: number | null,
150
+ tickrate: number,
151
+ tickStep: number,
152
+ ): ResponseObservation | null {
153
+ const frameSeconds = tickStep / tickrate;
154
+ let departureTick: number | null = null;
155
+ for (let index = 0; index < frames.length; index += 1) {
156
+ const frame = frames[index]!;
157
+ if (frame.tick < openingEndTick) continue;
158
+ const sample = frame.players.find((row) => row.playerIndex === playerIndex);
159
+ if (!sample) continue;
160
+ const location = groupAndRegion(mapName, sample);
161
+ if (location.group === initialGroup) {
162
+ departureTick = null;
163
+ continue;
164
+ }
165
+ departureTick ??= frame.tick;
166
+ const target = stableGroupAt(frames, index, playerIndex, mapName, frameSeconds);
167
+ if (!target || target.group === initialGroup) continue;
168
+ const returnStart = frames.findIndex((candidate, candidateIndex) => {
169
+ if (candidateIndex <= index || candidate.tick <= target.resolvedTick) return false;
170
+ const candidateSample = candidate.players.find((row) => row.playerIndex === playerIndex);
171
+ return candidateSample != null && groupAndRegion(mapName, candidateSample).group === initialGroup;
172
+ });
173
+ let returned: boolean | null = null;
174
+ if (returnStart >= 0 && stableGroupAt(frames, returnStart, playerIndex, mapName, frameSeconds)?.group === initialGroup) returned = true;
175
+ else if (deathTick == null) returned = false;
176
+ return {
177
+ leftTick: departureTick,
178
+ targetGroup: target.group,
179
+ targetRegion: target.region,
180
+ pathSeconds: Math.max(0, (frame.tick - departureTick) / tickrate),
181
+ initialAreaStillCovered: areaCovered(frames.find((candidate) => candidate.tick === departureTick), playerIndex, mapName, initialRegion),
182
+ returned,
183
+ resolvedTick: target.resolvedTick,
184
+ };
185
+ }
186
+ return null;
187
+ }
188
+
189
+ export function extractCtRotationRoundFacts(
190
+ pkg: DemoPackage,
191
+ matchId: string,
192
+ context: ReplayRoundContext | null,
193
+ round: DemoPackage["rounds"][number],
194
+ frames: readonly TeamSpatialFrame[],
195
+ grid: CalloutGrid | null,
196
+ hasNav: boolean,
197
+ ): CtRotationRoundFact[] {
198
+ const tickrate = pkg.match.tickrate || 64;
199
+ const teamKey = round.teamASide === "ct" ? "teamA" : "teamB";
200
+ const ctPlayers = pkg.players.flatMap((player, playerIndex) => player.teamKey === teamKey ? [{ player, playerIndex }] : []);
201
+ const deaths = new Map(pkg.kills.filter((row) => row.roundNumber === round.roundNumber).map((row) => [row.victimIndex, row.tick]));
202
+ if (!context) {
203
+ return ctPlayers.map(({ player, playerIndex }) => ({
204
+ analysisVersion: MAP_INTELLIGENCE_FACT_VERSION,
205
+ factVersion: CT_ROTATION_FACT_VERSION,
206
+ matchId,
207
+ mapName: pkg.match.mapName,
208
+ roundNumber: round.roundNumber,
209
+ teamKey,
210
+ side: "ct",
211
+ playerIndex,
212
+ steamId64: player.steamId64,
213
+ initialPositionGroupId: null,
214
+ initialRegion: null,
215
+ initialPositionGroupShare: null,
216
+ initialResponsibilityResolved: false,
217
+ initialWindowEligibleSeconds: null,
218
+ firstOwnAreaContactTick: null,
219
+ firstOtherAreaContactTick: null,
220
+ firstTeamContactTick: null,
221
+ leftInitialPositionGroupTick: null,
222
+ leaveDelayAfterFirstOtherAreaContactSeconds: null,
223
+ firstStableDestinationPositionGroupId: null,
224
+ firstStableDestinationRegion: null,
225
+ crossedResponsibilityArea: null,
226
+ returnedToInitialPositionGroup: null,
227
+ transitToStableDestinationSeconds: null,
228
+ crossAreaDepartureOrder: null,
229
+ firstCrossAreaDeparture: null,
230
+ priorCrossAreaDeparturesAlive: null,
231
+ initialAreaStillCovered: null,
232
+ deathTick: deaths.get(playerIndex) ?? null,
233
+ censoredByDeath: null,
234
+ roundEndTick: round.endTick,
235
+ availability: availability(pkg, null, null, grid, hasNav),
236
+ }));
237
+ }
238
+
239
+ const contacts = contactEvents(pkg, context, round, grid);
240
+ const openingWindow = openingResponsibilityWindow(round, tickrate);
241
+ const teamFrames = frames.filter((frame) => frame.teamKey === teamKey && frame.side === "ct");
242
+ const frameSeconds = context.tickStep / tickrate;
243
+ const rows = ctPlayers.map(({ player, playerIndex }): CtRotationRoundFact => {
244
+ const track = context.tracks.find((row) => row.playerIndex === playerIndex) ?? null;
245
+ const playerFrames = teamFrames.filter((frame) => frame.players.some((row) => row.playerIndex === playerIndex));
246
+ const openingFrames = playerFrames.filter((frame) => frame.tick >= openingWindow.startTick && frame.tick < openingWindow.endTick);
247
+ const groups = new Map<string, number>();
248
+ const regions = new Map<TacticalRegion, number>();
249
+ for (const frame of openingFrames) {
250
+ const sample = frame.players.find((row) => row.playerIndex === playerIndex)!;
251
+ const location = groupAndRegion(pkg.match.mapName, sample);
252
+ if (location.group) groups.set(location.group, (groups.get(location.group) ?? 0) + frameSeconds);
253
+ if (location.region) regions.set(location.region, (regions.get(location.region) ?? 0) + frameSeconds);
254
+ }
255
+ const initialGroup = [...groups.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? null;
256
+ const initialRegion = [...regions.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null;
257
+ const openingSeconds = track ? openingFrames.length * frameSeconds : null;
258
+ const deathTick = deaths.get(playerIndex) ?? null;
259
+ const response = initialGroup == null ? null : responseObservation(
260
+ teamFrames,
261
+ playerIndex,
262
+ pkg.match.mapName,
263
+ initialGroup[0],
264
+ initialRegion,
265
+ openingWindow.endTick,
266
+ deathTick,
267
+ tickrate,
268
+ context.tickStep,
269
+ );
270
+ const observableContacts = deathTick == null ? contacts : contacts.filter((contact) => contact.tick <= deathTick);
271
+ const firstTeamContact = observableContacts[0]?.tick ?? null;
272
+ const firstOwnContact = initialRegion == null ? null : observableContacts.find((row) => row.region === initialRegion)?.tick ?? null;
273
+ const firstOtherContact = initialRegion == null ? null : observableContacts.find((row) => row.region !== initialRegion)?.tick ?? null;
274
+ const crossed = response && initialRegion && response.targetRegion
275
+ ? response.targetRegion !== initialRegion
276
+ : response == null && deathTick == null && initialGroup != null && initialRegion != null
277
+ ? false
278
+ : null;
279
+ return {
280
+ analysisVersion: MAP_INTELLIGENCE_FACT_VERSION,
281
+ factVersion: CT_ROTATION_FACT_VERSION,
282
+ matchId,
283
+ mapName: pkg.match.mapName,
284
+ roundNumber: round.roundNumber,
285
+ teamKey,
286
+ side: "ct",
287
+ playerIndex,
288
+ steamId64: player.steamId64,
289
+ initialPositionGroupId: initialGroup?.[0] ?? null,
290
+ initialRegion,
291
+ initialPositionGroupShare: initialGroup == null || openingSeconds == null || openingSeconds === 0 ? null : rounded(initialGroup[1] / openingSeconds),
292
+ initialResponsibilityResolved: initialGroup != null && initialRegion != null,
293
+ initialWindowEligibleSeconds: rounded(openingSeconds),
294
+ firstOwnAreaContactTick: firstOwnContact,
295
+ firstOtherAreaContactTick: firstOtherContact,
296
+ firstTeamContactTick: firstTeamContact,
297
+ leftInitialPositionGroupTick: response?.leftTick ?? null,
298
+ leaveDelayAfterFirstOtherAreaContactSeconds: response && firstOtherContact != null ? rounded((response.leftTick - firstOtherContact) / tickrate) : null,
299
+ firstStableDestinationPositionGroupId: response?.targetGroup ?? null,
300
+ firstStableDestinationRegion: response?.targetRegion ?? null,
301
+ crossedResponsibilityArea: crossed,
302
+ returnedToInitialPositionGroup: response?.returned ?? null,
303
+ transitToStableDestinationSeconds: response == null ? null : rounded(response.pathSeconds),
304
+ crossAreaDepartureOrder: null,
305
+ firstCrossAreaDeparture: null,
306
+ priorCrossAreaDeparturesAlive: null,
307
+ initialAreaStillCovered: response?.initialAreaStillCovered ?? null,
308
+ deathTick,
309
+ censoredByDeath: track == null ? null : deathTick == null ? false : response == null || deathTick <= response.resolvedTick,
310
+ roundEndTick: round.endTick,
311
+ availability: availability(pkg, context, track, grid, hasNav),
312
+ };
313
+ });
314
+
315
+ const crossArea = rows.filter((row) => row.crossedResponsibilityArea === true && row.leftInitialPositionGroupTick != null);
316
+ for (const row of crossArea) {
317
+ const earlier = crossArea.filter((candidate) => candidate.leftInitialPositionGroupTick! < row.leftInitialPositionGroupTick!).length;
318
+ row.priorCrossAreaDeparturesAlive = crossArea.filter((candidate) => candidate.leftInitialPositionGroupTick! < row.leftInitialPositionGroupTick!
319
+ && (candidate.deathTick == null || candidate.deathTick > row.leftInitialPositionGroupTick!)).length;
320
+ row.crossAreaDepartureOrder = earlier + 1;
321
+ row.firstCrossAreaDeparture = earlier === 0;
322
+ }
323
+ return rows;
324
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ MAP_INTELLIGENCE_FACT_VERSION,
3
+ matchMapIntelligenceFactsSchema,
4
+ type DemoPackage,
5
+ type MatchMapIntelligenceFacts,
6
+ } from "@cs2dak/contract";
7
+ import { getMapNav, type CalloutGrid, type CompactNav } from "@cs2dak/maps";
8
+ import { createReplayRoundContexts, type ReplayRoundContext } from "../tactics/replay-round-context.js";
9
+ import { extractTacticalRoundFactsWithContexts, type TacticalRoundFact } from "../tactics/round-facts.js";
10
+ import { extractPlayerPositionRoundFacts } from "./player-position.js";
11
+ import { buildRoundSpatialFrames } from "./spatial.js";
12
+ import { extractTeamShapeRoundFacts } from "./team-shape.js";
13
+ import { extractTeamAwpRoundFacts } from "./team-awp-round.js";
14
+ import { extractCtRotationRoundFacts } from "./ct-rotation.js";
15
+
16
+ export { MAP_INTELLIGENCE_FACT_VERSION } from "@cs2dak/contract";
17
+ export { OPENING_RESPONSIBILITY_SECONDS } from "./opening-window.js";
18
+ export { CT_ROTATION_FACT_VERSION } from "@cs2dak/contract";
19
+ export { CT_ROTATION_RESPONSE_PERSISTENCE_SECONDS } from "./ct-rotation.js";
20
+ export type { CtRotationRoundFact, MatchMapIntelligenceFacts, PlayerPositionRoundFact, TeamAwpRoundFact, TeamShapeRoundFact } from "@cs2dak/contract";
21
+
22
+ export interface ExtractMatchMapIntelligenceFactsOptions {
23
+ matchId: string;
24
+ calloutGrid?: CalloutGrid | null;
25
+ nav?: CompactNav | null;
26
+ }
27
+
28
+ /**
29
+ * Public compact-facts facade. Contexts exist only for this call and are never returned or persisted.
30
+ */
31
+ export function extractMatchMapIntelligenceFacts(
32
+ pkg: DemoPackage,
33
+ options: ExtractMatchMapIntelligenceFactsOptions,
34
+ ): MatchMapIntelligenceFacts {
35
+ return extractMapIntelligenceWithContexts(pkg, options, createReplayRoundContexts(pkg));
36
+ }
37
+
38
+ function extractMapIntelligenceWithContexts(
39
+ pkg: DemoPackage,
40
+ options: ExtractMatchMapIntelligenceFactsOptions,
41
+ contexts: ReadonlyMap<number, ReplayRoundContext>,
42
+ ): MatchMapIntelligenceFacts {
43
+ const nav = options.nav === undefined ? getMapNav(pkg.match.mapName) : options.nav;
44
+ const playerPositionRounds = [];
45
+ const teamShapeRounds = [];
46
+ const teamAwpRounds = [];
47
+ const ctRotationRounds = [];
48
+ for (const round of pkg.rounds) {
49
+ const context = contexts.get(round.roundNumber) ?? null;
50
+ const frames = context ? buildRoundSpatialFrames(context, options.calloutGrid ?? null, nav) : [];
51
+ const positionRows = extractPlayerPositionRoundFacts(pkg, options.matchId, context, round, frames, options.calloutGrid ?? null, nav != null);
52
+ playerPositionRounds.push(...positionRows);
53
+ teamShapeRounds.push(...extractTeamShapeRoundFacts(pkg, options.matchId, context, round, frames, options.calloutGrid ?? null, nav != null));
54
+ teamAwpRounds.push(...extractTeamAwpRoundFacts(pkg, options.matchId, context, positionRows));
55
+ ctRotationRounds.push(...extractCtRotationRoundFacts(pkg, options.matchId, context, round, frames, options.calloutGrid ?? null, nav != null));
56
+ }
57
+ return matchMapIntelligenceFactsSchema.parse({ analysisVersion: MAP_INTELLIGENCE_FACT_VERSION, matchId: options.matchId, mapName: pkg.match.mapName, playerPositionRounds, teamShapeRounds, teamAwpRounds, ctRotationRounds });
58
+ }
59
+
60
+
61
+ /**
62
+ * Internal replay consumers share delta decoding through this compact public facade.
63
+ * The returned values are facts only; replay frames and pairwise graphs never escape core.
64
+ */
65
+ export function extractMatchTacticalAndMapIntelligenceFacts(
66
+ pkg: DemoPackage,
67
+ options: ExtractMatchMapIntelligenceFactsOptions,
68
+ ): { tacticalRounds: TacticalRoundFact[]; mapIntelligence: MatchMapIntelligenceFacts } {
69
+ const contexts = createReplayRoundContexts(pkg);
70
+ return {
71
+ tacticalRounds: extractTacticalRoundFactsWithContexts(pkg, options, contexts),
72
+ mapIntelligence: extractMapIntelligenceWithContexts(pkg, options, contexts),
73
+ };
74
+ }
@@ -0,0 +1,62 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { loadDemoPackageFromZip } from "../loader.js";
5
+ import { extractMatchMapIntelligenceFacts } from "./index.js";
6
+
7
+ async function fixture(name: string) {
8
+ return loadDemoPackageFromZip(await readFile(fileURLToPath(new URL(`../../../../fixtures/input/${name}`, import.meta.url))));
9
+ }
10
+
11
+ describe("extractMatchMapIntelligenceFacts", () => {
12
+ it("derives compact Ancient position and shape facts without persisting replay frames", async () => {
13
+ const pkg = await fixture("sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip");
14
+ const facts = extractMatchMapIntelligenceFacts(pkg, { matchId: "ancient" });
15
+ expect(facts.playerPositionRounds).toHaveLength(pkg.rounds.length * pkg.players.length);
16
+ expect(facts.teamShapeRounds).toHaveLength(pkg.rounds.length * 2);
17
+ expect(facts.teamAwpRounds).toHaveLength(pkg.rounds.length * 2);
18
+ expect(facts.ctRotationRounds).toHaveLength(pkg.rounds.length * pkg.players.length / 2);
19
+ expect(facts.playerPositionRounds.some((row) => row.positionGroupDwell.length > 0)).toBe(true);
20
+ expect(facts.playerPositionRounds.some((row) => row.openingPositionGroupDwell.length > 0)).toBe(true);
21
+ expect(facts.playerPositionRounds.every((row) => row.openingEligibleSeconds == null || row.eligibleSeconds == null || row.openingEligibleSeconds <= row.eligibleSeconds)).toBe(true);
22
+ expect(facts.teamShapeRounds.some((row) => row.windows.some((window) => /^\d+(\+\d+)*$/.test(window.partition)))).toBe(true);
23
+ expect(facts.teamShapeRounds.some((row) => row.openingWindows.length > 0)).toBe(true);
24
+ expect(facts.ctRotationRounds.some((row) => row.initialResponsibilityResolved)).toBe(true);
25
+ expect(facts.ctRotationRounds.every((row) => row.side === "ct" && !("role" in row))).toBe(true);
26
+ expect(extractMatchMapIntelligenceFacts(pkg, { matchId: "ancient" }).ctRotationRounds).toEqual(facts.ctRotationRounds);
27
+ const serialized = JSON.stringify(facts);
28
+ expect(serialized).not.toContain('"frameCount"');
29
+ expect(serialized).not.toContain('"pairwise"');
30
+ });
31
+
32
+ it("keeps Nuke fixture output compact and maps no sniper other than exact AWP into AWP facts", async () => {
33
+ const pkg = await fixture("sample-2026-02-09_de_nuke_FURIA_2-13_Team_Vitality.zip");
34
+ const facts = extractMatchMapIntelligenceFacts(pkg, { matchId: "nuke" });
35
+ expect(facts.mapName).toBe("de_nuke");
36
+ expect(facts.playerPositionRounds.every((row) => row.awpShots == null || row.awpShots >= 0)).toBe(true);
37
+ expect(facts.teamShapeRounds.every((row) => row.availability.nav === "available")).toBe(true);
38
+ });
39
+
40
+ it.each([
41
+ "sample-2026-02-09_de_inferno_Team_Vitality_13-8_FURIA.zip",
42
+ "sample-2026-02-09_de_overpass_Team_Vitality_13-10_FURIA.zip",
43
+ ])("retains opening path and multi-route position evidence for %s", async (name) => {
44
+ const pkg = await fixture(name);
45
+ const facts = extractMatchMapIntelligenceFacts(pkg, { matchId: name });
46
+ expect(facts.playerPositionRounds.some((row) => row.openingPath.length > 1)).toBe(true);
47
+ expect(new Set(facts.playerPositionRounds.flatMap((row) => row.openingPositionGroupDwell.map((group) => group.positionGroupId))).size).toBeGreaterThan(1);
48
+ });
49
+
50
+ it("uses explicit missing/unknown state when replay is unavailable and degrades Anubis without nav", async () => {
51
+ const pkg = await fixture("sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip");
52
+ const noReplay = { ...pkg, replay: undefined };
53
+ const missing = extractMatchMapIntelligenceFacts(noReplay, { matchId: "missing" });
54
+ expect(missing.playerPositionRounds.every((row) => row.openingEligibleSeconds === null && row.eligibleSeconds === null && row.activeAwpSeconds === null && row.availability.replay === "missing")).toBe(true);
55
+ expect(missing.teamShapeRounds.every((row) => row.openingWindows.length === 0 && row.windows.length === 0 && row.coverageSeconds === null)).toBe(true);
56
+ expect(missing.teamAwpRounds.every((row) => row.awpActiveSeconds === null && row.availability.replay === "missing")).toBe(true);
57
+ expect(missing.ctRotationRounds.every((row) => row.initialPositionGroupId === null && row.crossedResponsibilityArea === null && row.availability.replay === "missing")).toBe(true);
58
+
59
+ const anubis = extractMatchMapIntelligenceFacts({ ...pkg, match: { ...pkg.match, mapName: "de_anubis" } }, { matchId: "anubis", nav: null });
60
+ expect(anubis.teamShapeRounds.every((row) => row.availability.nav === "missing")).toBe(true);
61
+ });
62
+ });
@@ -0,0 +1,19 @@
1
+ import { OPENING_RESPONSIBILITY_WINDOW_VERSION, type ResponsibilityWindow } from "@cs2dak/contract";
2
+
3
+ /**
4
+ * Default-position/responsibility evidence ends before normal mid-round rotations dominate.
5
+ * Bump the public window version whenever this duration or anchoring semantics change.
6
+ */
7
+ export const OPENING_RESPONSIBILITY_SECONDS = 20;
8
+
9
+ export function openingResponsibilityWindow(
10
+ round: { freezeEndTick: number; endTick: number },
11
+ tickrate: number,
12
+ ): ResponsibilityWindow {
13
+ return {
14
+ version: OPENING_RESPONSIBILITY_WINDOW_VERSION,
15
+ startTick: round.freezeEndTick,
16
+ endTick: Math.min(round.endTick, round.freezeEndTick + Math.round(OPENING_RESPONSIBILITY_SECONDS * (tickrate || 64))),
17
+ configuredSeconds: OPENING_RESPONSIBILITY_SECONDS,
18
+ };
19
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TeamSpatialFrame } from "./spatial.js";
3
+ import { detectDelayedConvergences } from "./player-position.js";
4
+
5
+ function frame(tick: number, components: number[][]): TeamSpatialFrame {
6
+ return {
7
+ tick, teamKey: "teamA", side: "t", components,
8
+ players: components.flat().map((playerIndex) => ({ playerIndex, track: {} as never, point: { x: playerIndex, y: 0, z: 0 }, callout: null, navAreaId: null })),
9
+ };
10
+ }
11
+
12
+ describe("delayed convergence", () => {
13
+ it("requires prior isolation, a pre-existing stable group and persistent convergence", () => {
14
+ const frames = [
15
+ frame(0, [[1, 2, 3], [0]]), frame(1, [[1, 2, 3], [0]]),
16
+ frame(2, [[0, 1, 2, 3]]), frame(3, [[0, 1, 2, 3]]),
17
+ ];
18
+ expect(detectDelayedConvergences(frames, 0, 1, 1)).toEqual([{ tick: 2, priorIsolationSeconds: 2, joinedComponentSize: 4, persistenceSeconds: 2 }]);
19
+ });
20
+
21
+ it("rejects a topology change caused by a teammate death", () => {
22
+ const frames = [
23
+ frame(0, [[1, 2, 3], [0]]), frame(1, [[1, 2, 3], [0]]),
24
+ frame(2, [[0, 1, 2]]), frame(3, [[0, 1, 2]]),
25
+ ];
26
+ expect(detectDelayedConvergences(frames, 0, 1, 1)).toEqual([]);
27
+ });
28
+ });