@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,406 @@
1
+ import {
2
+ FLAG_ALIVE,
3
+ FLAG_HAS_BOMB,
4
+ type DemoPackage,
5
+ type EconomyType,
6
+ type Side,
7
+ type TeamKey,
8
+ } from "@cs2dak/contract";
9
+ import {
10
+ calloutNear,
11
+ getCalloutTendencies,
12
+ getPrimaryCalloutRegion,
13
+ resolveSiteEntry,
14
+ type CalloutGrid,
15
+ type TacticalRegion,
16
+ type Vec3,
17
+ } from "@cs2dak/maps";
18
+ import { buildPlayerTacticalSegments } from "./segments.js";
19
+ import { deriveOpeningPattern, deriveOpeningPressure } from "./formations.js";
20
+ import { createReplayRoundContext, replayCalloutAt, replayTickAt, type ReplayRoundContext } from "./replay-round-context.js";
21
+ import type { OpeningPattern, OpeningPressureEvent, TacticalFrameSample } from "./types.js";
22
+
23
+ export const TACTICAL_FACT_VERSION = 9;
24
+ export type ExecuteBucket = "rush" | "fast" | "mid" | "late";
25
+
26
+ export interface SiteEntryOccurrence {
27
+ playerIndex: number;
28
+ tick: number;
29
+ remainSec: number;
30
+ callout: string;
31
+ /** 原始最后一个非包点 callout,仅作为证据。 */
32
+ entryCallout: string | null;
33
+ entryChokeId: string | null;
34
+ routeFamilyId: string | null;
35
+ routeMarkerCallout: string | null;
36
+ /** freeze end 到首次进点的去重 callout 序列。 */
37
+ trajectory: string[];
38
+ }
39
+
40
+ export interface SiteEntryFact {
41
+ entrants: number;
42
+ firstEntryTick: number | null;
43
+ secondEntryTick: number | null;
44
+ firstEntryRemainSec: number | null;
45
+ executeRemainSec: number | null;
46
+ distinctEntryChokeIds?: string[];
47
+ entrySpanSec?: number | null;
48
+ order: SiteEntryOccurrence[];
49
+ }
50
+
51
+ export interface TacticalPlantFact {
52
+ site: "a" | "b";
53
+ tick: number;
54
+ remainSec: number;
55
+ }
56
+
57
+ export interface TacticalGrenadeOccurrence {
58
+ id: string;
59
+ type: string;
60
+ throwTick: number;
61
+ effectTick: number | null;
62
+ throwPosition: Vec3;
63
+ effectPosition: Vec3;
64
+ throwCallout: string | null;
65
+ effectCallout: string | null;
66
+ effectCalloutSource: "exact" | "nearby" | null;
67
+ effectCalloutDistance: number | null;
68
+ confidence: number;
69
+ samples: number;
70
+ targetRegion: TacticalRegion | "other" | "unknown";
71
+ tendencies?: readonly TacticalRegion[];
72
+ }
73
+
74
+ export interface C4RouteFact {
75
+ /** 原始相邻去重轨迹,仅作为证据,不直接等同于战术。 */
76
+ callouts: string[];
77
+ startRegion: TacticalRegion | "other" | null;
78
+ endRegion: TacticalRegion | "other" | null;
79
+ rotated: boolean;
80
+ plantCallout: string | null;
81
+ }
82
+
83
+ export interface TacticalRoundFact {
84
+ analysisVersion: number;
85
+ matchId: string;
86
+ mapName: string;
87
+ side: Side;
88
+ teamKey: TeamKey;
89
+ teamName: string;
90
+ opponentName: string;
91
+ /** ZIP rounds.json 的本方原生经济。 */
92
+ economy: EconomyType;
93
+ opponentEconomy: EconomyType;
94
+ won: boolean;
95
+ roundNumber: number;
96
+ openingPattern: OpeningPattern;
97
+ openingPressure: OpeningPressureEvent[];
98
+ targetSite: "a" | "b" | null;
99
+ siteEntries: { a: SiteEntryFact; b: SiteEntryFact };
100
+ plant: TacticalPlantFact | null;
101
+ grenades: TacticalGrenadeOccurrence[];
102
+ c4Route: C4RouteFact | null;
103
+ executeRemainSec: number | null;
104
+ executeBucket: ExecuteBucket | null;
105
+ firstKillForTeam: boolean | null;
106
+ grenadeOccurrenceIds: string[];
107
+ }
108
+
109
+ function locateGrid(grid: CalloutGrid | null, point: Vec3) {
110
+ if (!grid) return null;
111
+ return calloutNear(grid, point, { horizontalRadius: 20, verticalRadius: 40 });
112
+ }
113
+
114
+ const ROUND_SECONDS = 115;
115
+
116
+ function remainSecAt(tick: number, freezeEndTick: number, tickrate: number): number {
117
+ return Math.max(0, Math.round(ROUND_SECONDS - (tick - freezeEndTick) / tickrate));
118
+ }
119
+
120
+ function bucketOf(remainSec: number | null): ExecuteBucket | null {
121
+ if (remainSec == null) return null;
122
+ if (remainSec > 95) return "rush";
123
+ if (remainSec > 70) return "fast";
124
+ if (remainSec > 40) return "mid";
125
+ return "late";
126
+ }
127
+
128
+ function economyFor(round: DemoPackage["rounds"][number], teamKey: TeamKey): EconomyType {
129
+ return teamKey === "teamA" ? round.teamAEconomy : round.teamBEconomy;
130
+ }
131
+
132
+ function emptySiteEntry(): SiteEntryFact {
133
+ return {
134
+ entrants: 0,
135
+ firstEntryTick: null,
136
+ secondEntryTick: null,
137
+ firstEntryRemainSec: null,
138
+ executeRemainSec: null,
139
+ distinctEntryChokeIds: [],
140
+ entrySpanSec: null,
141
+ order: [],
142
+ };
143
+ }
144
+
145
+ function openingAnalysisFor(
146
+ decoded: ReplayRoundContext | null,
147
+ round: DemoPackage["rounds"][number],
148
+ mapName: string,
149
+ side: Side,
150
+ tickrate: number,
151
+ grid: CalloutGrid | null,
152
+ ): { openingPattern: OpeningPattern; openingPressure: OpeningPressureEvent[] } {
153
+ const endTick = Math.min(round.endTick, round.freezeEndTick + 30 * tickrate);
154
+ const samples: TacticalFrameSample[] = [];
155
+ for (const track of decoded?.tracks ?? []) {
156
+ if (track.side !== side) continue;
157
+ for (let index = 0; index < (decoded?.frameCount ?? 0); index += 1) {
158
+ const tick = replayTickAt(decoded!, index);
159
+ if (tick < round.freezeEndTick) continue;
160
+ if (tick > endTick) break;
161
+ const alive = ((track.flags[index] ?? 0) & FLAG_ALIVE) !== 0;
162
+ samples.push({
163
+ tick,
164
+ playerIndex: track.playerIndex,
165
+ side,
166
+ alive,
167
+ callout: alive ? replayCalloutAt(decoded!, track, index, grid) : null,
168
+ });
169
+ }
170
+ }
171
+ const segments = buildPlayerTacticalSegments(samples, {
172
+ mapName,
173
+ tickrate,
174
+ maxGapTicks: decoded ? decoded.tickStep * 2 : undefined,
175
+ });
176
+ return {
177
+ openingPattern: deriveOpeningPattern(segments, { side, startTick: round.freezeEndTick, endTick }),
178
+ openingPressure: deriveOpeningPressure(segments, { mapName, side, startTick: round.freezeEndTick, endTick }),
179
+ };
180
+ }
181
+
182
+ function siteEntriesFor(
183
+ decoded: ReplayRoundContext | null,
184
+ round: DemoPackage["rounds"][number],
185
+ mapName: string,
186
+ side: Side,
187
+ tickrate: number,
188
+ grid: CalloutGrid | null,
189
+ ): { a: SiteEntryFact; b: SiteEntryFact } {
190
+ if (!decoded) return { a: emptySiteEntry(), b: emptySiteEntry() };
191
+ const bySite = {
192
+ a: new Map<number, SiteEntryOccurrence>(),
193
+ b: new Map<number, SiteEntryOccurrence>(),
194
+ };
195
+ for (const track of decoded.tracks) {
196
+ if (track.side !== side) continue;
197
+ const seen = new Set<"a" | "b">();
198
+ const trajectory: string[] = [];
199
+ for (let index = 0; index < decoded.frameCount; index += 1) {
200
+ const tick = replayTickAt(decoded, index);
201
+ if (tick < round.freezeEndTick || tick > round.endTick) continue;
202
+ if (((track.flags[index] ?? 0) & FLAG_ALIVE) === 0) break;
203
+ const callout = replayCalloutAt(decoded, track, index, grid);
204
+ if (!callout) continue;
205
+ if (callout !== trajectory.at(-1)) trajectory.push(callout);
206
+ const site = callout === "BombsiteA" ? "a" : callout === "BombsiteB" ? "b" : null;
207
+ if (!site || seen.has(site)) continue;
208
+ seen.add(site);
209
+ const resolved = resolveSiteEntry(mapName, site, trajectory);
210
+ bySite[site].set(track.playerIndex, {
211
+ playerIndex: track.playerIndex,
212
+ tick,
213
+ remainSec: remainSecAt(tick, round.freezeEndTick, tickrate),
214
+ callout,
215
+ entryCallout: trajectory.at(-2) ?? null,
216
+ entryChokeId: resolved.entryChokeId,
217
+ routeFamilyId: resolved.routeFamilyId,
218
+ routeMarkerCallout: resolved.routeMarkerCallout,
219
+ trajectory: [...trajectory],
220
+ });
221
+ }
222
+ }
223
+ const build = (site: "a" | "b"): SiteEntryFact => {
224
+ const order = [...bySite[site].values()].sort((a, b) => a.tick - b.tick || a.playerIndex - b.playerIndex);
225
+ const first = order[0];
226
+ const last = order.at(-1);
227
+ return {
228
+ entrants: order.length,
229
+ firstEntryTick: first?.tick ?? null,
230
+ secondEntryTick: order[1]?.tick ?? null,
231
+ firstEntryRemainSec: first?.remainSec ?? null,
232
+ executeRemainSec: order[1]?.remainSec ?? null,
233
+ distinctEntryChokeIds: [...new Set(order.flatMap((row) => row.entryChokeId ? [row.entryChokeId] : []))].sort(),
234
+ entrySpanSec: first && last ? Math.round(((last.tick - first.tick) / tickrate) * 10) / 10 : null,
235
+ order,
236
+ };
237
+ };
238
+ return { a: build("a"), b: build("b") };
239
+ }
240
+
241
+ function targetRegion(mapName: string, callout: string | null): TacticalGrenadeOccurrence["targetRegion"] {
242
+ if (!callout) return "unknown";
243
+ return getPrimaryCalloutRegion(mapName, callout) ?? "unknown";
244
+ }
245
+
246
+ function plantFor(pkg: DemoPackage, round: DemoPackage["rounds"][number], tickrate: number): TacticalPlantFact | null {
247
+ const plant = pkg.bombs.find((event) =>
248
+ event.roundNumber === round.roundNumber && event.type === "planted" && (event.site === "a" || event.site === "b"),
249
+ );
250
+ if (!plant || (plant.site !== "a" && plant.site !== "b")) return null;
251
+ return { site: plant.site, tick: plant.tick, remainSec: remainSecAt(plant.tick, round.freezeEndTick, tickrate) };
252
+ }
253
+
254
+ function targetSiteFor(plant: TacticalPlantFact | null, entries: { a: SiteEntryFact; b: SiteEntryFact }): "a" | "b" | null {
255
+ if (plant) return plant.site;
256
+ if (entries.a.entrants > entries.b.entrants) return "a";
257
+ if (entries.b.entrants > entries.a.entrants) return "b";
258
+ return null;
259
+ }
260
+
261
+ function throwerCallout(pkg: DemoPackage, roundNumber: number, playerIndex: number, tick: number): string | null {
262
+ const replayRound = pkg.replay?.rounds.find((row) => row.roundNumber === roundNumber);
263
+ const track = replayRound?.players.find((row) => row.playerIndex === playerIndex);
264
+ if (!pkg.replay || !replayRound || !track) return null;
265
+ const frame = Math.max(0, Math.min(replayRound.frameCount - 1, Math.round((tick - replayRound.startTick) / replayRound.tickStep)));
266
+ return pkg.replay.placeDict[track.place[frame] ?? -1] ?? null;
267
+ }
268
+
269
+ function sideOf(pkg: DemoPackage, playerIndex: number, round: DemoPackage["rounds"][number]): Side | null {
270
+ const player = pkg.players[playerIndex];
271
+ if (!player) return null;
272
+ return player.teamKey === "teamA" ? round.teamASide : round.teamBSide;
273
+ }
274
+
275
+ function grenadesFor(
276
+ pkg: DemoPackage,
277
+ matchId: string,
278
+ round: DemoPackage["rounds"][number],
279
+ side: Side,
280
+ grid: CalloutGrid | null,
281
+ ): TacticalGrenadeOccurrence[] {
282
+ return (pkg.grenades ?? [])
283
+ .map((grenade, index) => ({ grenade, index }))
284
+ .filter(({ grenade }) => grenade.roundNumber === round.roundNumber && sideOf(pkg, grenade.throwerIndex, round) === side)
285
+ .map(({ grenade, index }) => {
286
+ const located = locateGrid(grid, grenade.effectPosition);
287
+ const effectCallout = located?.callout ?? null;
288
+ return {
289
+ id: grenade.grenadeId ?? `${matchId}:r${grenade.roundNumber}:g${index}`,
290
+ type: grenade.grenade,
291
+ throwTick: grenade.throwTick,
292
+ effectTick: grenade.effectTick ?? null,
293
+ throwPosition: grenade.throwPosition,
294
+ effectPosition: grenade.effectPosition,
295
+ throwCallout: throwerCallout(pkg, grenade.roundNumber, grenade.throwerIndex, grenade.throwTick),
296
+ effectCallout,
297
+ effectCalloutSource: located?.source ?? null,
298
+ effectCalloutDistance: located?.distance ?? null,
299
+ confidence: located?.confidence ?? 0,
300
+ samples: located?.samples ?? 0,
301
+ targetRegion: targetRegion(pkg.match.mapName, effectCallout),
302
+ tendencies: effectCallout ? getCalloutTendencies(pkg.match.mapName, effectCallout) ?? [] : [],
303
+ };
304
+ });
305
+ }
306
+
307
+ function c4RouteFor(
308
+ decoded: ReplayRoundContext | null,
309
+ round: DemoPackage["rounds"][number],
310
+ mapName: string,
311
+ plant: TacticalPlantFact | null,
312
+ grid: CalloutGrid | null,
313
+ ): C4RouteFact | null {
314
+ if (!decoded) return null;
315
+ const callouts: string[] = [];
316
+ let carrierIndex = -1;
317
+ for (let index = 0; index < decoded.frameCount; index += 1) {
318
+ const tick = replayTickAt(decoded, index);
319
+ if (tick < round.freezeEndTick || tick > round.endTick) continue;
320
+ const cached = decoded.tracks[carrierIndex];
321
+ if (!cached || ((cached.flags[index] ?? 0) & FLAG_ALIVE) === 0 || ((cached.flags[index] ?? 0) & FLAG_HAS_BOMB) === 0) {
322
+ carrierIndex = decoded.tracks.findIndex((track) =>
323
+ ((track.flags[index] ?? 0) & FLAG_ALIVE) !== 0 && ((track.flags[index] ?? 0) & FLAG_HAS_BOMB) !== 0,
324
+ );
325
+ }
326
+ if (carrierIndex < 0) continue;
327
+ const callout = replayCalloutAt(decoded, decoded.tracks[carrierIndex]!, index, grid);
328
+ if (callout && callout !== callouts.at(-1)) callouts.push(callout);
329
+ }
330
+ if (callouts.length === 0) return null;
331
+ const regions = callouts.map((callout) => targetRegion(mapName, callout)).filter((region) => region !== "unknown");
332
+ const directional = regions.filter((region): region is "a" | "b" => region === "a" || region === "b");
333
+ return {
334
+ callouts,
335
+ startRegion: regions[0] ?? null,
336
+ endRegion: regions.at(-1) ?? null,
337
+ rotated: Boolean(directional[0] && directional.at(-1) && directional[0] !== directional.at(-1)),
338
+ plantCallout: plant ? callouts.at(-1) ?? null : null,
339
+ };
340
+ }
341
+
342
+ function firstKillForTeam(pkg: DemoPackage, roundNumber: number, teamKey: TeamKey): boolean | null {
343
+ const first = pkg.kills.filter((kill) => kill.roundNumber === roundNumber).sort((a, b) => a.tick - b.tick)[0];
344
+ if (first?.killerIndex == null) return null;
345
+ return pkg.players[first.killerIndex]?.teamKey === teamKey;
346
+ }
347
+
348
+ function teamName(pkg: DemoPackage, teamKey: TeamKey): string {
349
+ return pkg.match[teamKey].name ?? teamKey;
350
+ }
351
+
352
+ export function extractTacticalRoundFacts(
353
+ pkg: DemoPackage,
354
+ options: { matchId: string; calloutGrid?: CalloutGrid | null },
355
+ ): TacticalRoundFact[] {
356
+ return extractTacticalRoundFactsWithContexts(pkg, options, undefined);
357
+ }
358
+
359
+ /** Internal composition seam: contexts never cross the @cs2dak/core public boundary. */
360
+ export function extractTacticalRoundFactsWithContexts(
361
+ pkg: DemoPackage,
362
+ options: { matchId: string; calloutGrid?: CalloutGrid | null },
363
+ replayContexts: ReadonlyMap<number, ReplayRoundContext> | undefined,
364
+ ): TacticalRoundFact[] {
365
+ const tickrate = pkg.match.tickrate || 64;
366
+ const out: TacticalRoundFact[] = [];
367
+ for (const round of pkg.rounds) {
368
+ const decoded = replayContexts?.get(round.roundNumber) ?? createReplayRoundContext(pkg, round);
369
+ const plant = plantFor(pkg, round, tickrate);
370
+ for (const side of ["t", "ct"] as const) {
371
+ if (!decoded?.tracks.some((track) => track.side === side)) continue;
372
+ const teamKey: TeamKey = round.teamASide === side ? "teamA" : "teamB";
373
+ const opponentKey: TeamKey = teamKey === "teamA" ? "teamB" : "teamA";
374
+ const entries = siteEntriesFor(decoded, round, pkg.match.mapName, side, tickrate, options.calloutGrid ?? null);
375
+ const opening = openingAnalysisFor(decoded, round, pkg.match.mapName, side, tickrate, options.calloutGrid ?? null);
376
+ const targetSite = targetSiteFor(plant, entries);
377
+ const executeRemainSec = targetSite ? entries[targetSite].executeRemainSec : null;
378
+ const grenades = grenadesFor(pkg, options.matchId, round, side, options.calloutGrid ?? null);
379
+ out.push({
380
+ analysisVersion: TACTICAL_FACT_VERSION,
381
+ matchId: options.matchId,
382
+ mapName: pkg.match.mapName,
383
+ side,
384
+ teamKey,
385
+ teamName: teamName(pkg, teamKey),
386
+ opponentName: teamName(pkg, opponentKey),
387
+ economy: economyFor(round, teamKey),
388
+ opponentEconomy: economyFor(round, opponentKey),
389
+ won: round.winnerSide === side,
390
+ roundNumber: round.roundNumber,
391
+ openingPattern: opening.openingPattern,
392
+ openingPressure: opening.openingPressure,
393
+ targetSite,
394
+ siteEntries: entries,
395
+ plant,
396
+ grenades,
397
+ c4Route: side === "t" ? c4RouteFor(decoded, round, pkg.match.mapName, plant, options.calloutGrid ?? null) : null,
398
+ executeRemainSec,
399
+ executeBucket: bucketOf(executeRemainSec),
400
+ firstKillForTeam: firstKillForTeam(pkg, round.roundNumber, teamKey),
401
+ grenadeOccurrenceIds: grenades.map((grenade) => grenade.id),
402
+ });
403
+ }
404
+ }
405
+ return out;
406
+ }
@@ -0,0 +1,68 @@
1
+ import { classifyTacticalLocation } from "@cs2dak/maps";
2
+ import type { PlayerTacticalSegment, TacticalFrameSample } from "./types.js";
3
+
4
+ export interface BuildSegmentsOptions {
5
+ mapName: string;
6
+ tickrate: number;
7
+ maxGapTicks?: number;
8
+ }
9
+
10
+ export function buildPlayerTacticalSegments(
11
+ samples: readonly TacticalFrameSample[],
12
+ options: BuildSegmentsOptions,
13
+ ): PlayerTacticalSegment[] {
14
+ const byPlayer = new Map<number, TacticalFrameSample[]>();
15
+ for (const sample of samples) {
16
+ const rows = byPlayer.get(sample.playerIndex) ?? [];
17
+ rows.push(sample);
18
+ byPlayer.set(sample.playerIndex, rows);
19
+ }
20
+
21
+ const segments: PlayerTacticalSegment[] = [];
22
+ for (const rows of byPlayer.values()) {
23
+ rows.sort((a, b) => a.tick - b.tick);
24
+ let active: TacticalFrameSample | null = null;
25
+ let endTick = 0;
26
+ const close = (): void => {
27
+ if (!active?.callout) return;
28
+ const location = classifyTacticalLocation(options.mapName, active.side, active.callout);
29
+ segments.push({
30
+ playerIndex: active.playerIndex,
31
+ side: active.side,
32
+ startTick: active.tick,
33
+ endTick,
34
+ durationSec: Math.max(0, (endTick - active.tick) / options.tickrate),
35
+ callout: active.callout,
36
+ tendencies: location.tendencies,
37
+ primaryRegion: location.primaryRegion,
38
+ positionGroupId: location.positionGroupId,
39
+ evidence: {
40
+ type: "dwell",
41
+ tick: active.tick,
42
+ endTick,
43
+ playerIndices: [active.playerIndex],
44
+ callouts: [active.callout],
45
+ },
46
+ });
47
+ active = null;
48
+ };
49
+
50
+ for (const sample of rows) {
51
+ if (!sample.alive || !sample.callout) {
52
+ close();
53
+ continue;
54
+ }
55
+ const gapExceeded = active != null
56
+ && options.maxGapTicks != null
57
+ && sample.tick - endTick > options.maxGapTicks;
58
+ const changed = active != null
59
+ && (active.callout !== sample.callout || active.side !== sample.side);
60
+ if (gapExceeded || changed) close();
61
+ if (!active) active = sample;
62
+ endTick = sample.tick;
63
+ }
64
+ close();
65
+ }
66
+
67
+ return segments.sort((a, b) => a.startTick - b.startTick || a.playerIndex - b.playerIndex);
68
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildFormationTimeline,
4
+ buildPlayerTacticalSegments,
5
+ deriveOpeningPattern,
6
+ deriveOpeningPressure,
7
+ type TacticalFrameSample,
8
+ } from "./index.js";
9
+
10
+ const base = (partial: Partial<TacticalFrameSample>): TacticalFrameSample => ({
11
+ tick: 0,
12
+ playerIndex: 1,
13
+ side: "t",
14
+ alive: true,
15
+ callout: "TRamp",
16
+ ...partial,
17
+ });
18
+
19
+ describe("tactical spatial kernel", () => {
20
+ it("将连续同 callout 样本合并,并在死亡、缺失、换区或采样断裂时截断", () => {
21
+ const segments = buildPlayerTacticalSegments([
22
+ base({ tick: 0 }),
23
+ base({ tick: 64 }),
24
+ base({ tick: 128, callout: "PalaceAlley" }),
25
+ base({ tick: 192, alive: false, callout: null }),
26
+ base({ tick: 256, callout: "TopofMid" }),
27
+ base({ tick: 640, callout: "TopofMid" }),
28
+ ], { mapName: "de_mirage", tickrate: 64, maxGapTicks: 128 });
29
+
30
+ expect(segments.map((row) => [row.callout, row.startTick, row.endTick])).toEqual([
31
+ ["TRamp", 0, 64],
32
+ ["PalaceAlley", 128, 128],
33
+ ["TopofMid", 256, 256],
34
+ ["TopofMid", 640, 640],
35
+ ]);
36
+ expect(segments[0]).toMatchObject({ primaryRegion: "a", positionGroupId: "a_ramp" });
37
+ expect(segments[2]).toMatchObject({ primaryRegion: "mid", positionGroupId: "top_mid" });
38
+ });
39
+
40
+ it("阵型快照对未知 callout 保持 unknown,不进行名称猜测", () => {
41
+ const timeline = buildFormationTimeline([
42
+ base({ tick: 64, playerIndex: 1, callout: "TRamp" }),
43
+ base({ tick: 64, playerIndex: 2, callout: "NoSuchPlace" }),
44
+ base({ tick: 64, playerIndex: 3, callout: null }),
45
+ ], { mapName: "de_mirage" });
46
+
47
+ expect(timeline).toHaveLength(1);
48
+ expect(timeline[0]?.regionCounts).toEqual({ a: 1, b: 0, mid: 0, unknown: 2 });
49
+ expect(timeline[0]?.positionGroupCounts).toEqual({ a_ramp: 1 });
50
+ });
51
+
52
+ it("开局粗签名只看区域结构,详细签名保留默认位", () => {
53
+ const first = buildPlayerTacticalSegments([
54
+ base({ tick: 0, playerIndex: 1, callout: "TRamp" }),
55
+ base({ tick: 64, playerIndex: 1, callout: "TRamp" }),
56
+ base({ tick: 0, playerIndex: 2, callout: "TopofMid" }),
57
+ base({ tick: 64, playerIndex: 2, callout: "TopofMid" }),
58
+ base({ tick: 0, playerIndex: 3, callout: "Apartments" }),
59
+ base({ tick: 64, playerIndex: 3, callout: "Apartments" }),
60
+ ], { mapName: "de_mirage", tickrate: 64 });
61
+ const shifted = buildPlayerTacticalSegments([
62
+ base({ tick: 0, playerIndex: 1, callout: "PalaceAlley" }),
63
+ base({ tick: 64, playerIndex: 1, callout: "PalaceAlley" }),
64
+ base({ tick: 0, playerIndex: 2, callout: "SideAlley" }),
65
+ base({ tick: 64, playerIndex: 2, callout: "SideAlley" }),
66
+ base({ tick: 0, playerIndex: 3, callout: "BackAlley" }),
67
+ base({ tick: 64, playerIndex: 3, callout: "BackAlley" }),
68
+ ], { mapName: "de_mirage", tickrate: 64 });
69
+
70
+ const a = deriveOpeningPattern(first, { side: "t", startTick: 0, endTick: 128 });
71
+ const b = deriveOpeningPattern(shifted, { side: "t", startTick: 0, endTick: 128 });
72
+ expect(a.coarseSignature).toBe("T:1A-1MID-1B:balanced");
73
+ expect(b.coarseSignature).toBe(a.coarseSignature);
74
+ expect(a.detailedSignature).toContain("a_ramp:1");
75
+ expect(a.evidence.every((row) => row.tick != null && row.playerIndices?.length === 1)).toBe(true);
76
+ });
77
+
78
+ it("开局代表位置优先采用已确认默认位,避免出生点长驻留淹没展开", () => {
79
+ const segments = buildPlayerTacticalSegments([
80
+ base({ tick: 0, callout: "TSpawn" }),
81
+ base({ tick: 64, callout: "TSpawn" }),
82
+ base({ tick: 128, callout: "TSpawn" }),
83
+ base({ tick: 192, callout: "TRamp" }),
84
+ base({ tick: 256, callout: "TRamp" }),
85
+ ], { mapName: "de_mirage", tickrate: 64 });
86
+
87
+ const opening = deriveOpeningPattern(segments, { side: "t", startTick: 0, endTick: 320 });
88
+ expect(opening.regionCounts).toEqual({ a: 1, b: 0, mid: 0, unknown: 0 });
89
+ expect(opening.positionGroupCounts).toEqual({ a_ramp: 1 });
90
+ });
91
+
92
+ it("CT 快速进入 T 默认位时标记深度前压,并保留中文 callout 证据", () => {
93
+ const segments = buildPlayerTacticalSegments([
94
+ base({ side: "ct", tick: 0, callout: "CTSpawn" }),
95
+ base({ side: "ct", tick: 64, callout: "Outside" }),
96
+ base({ side: "ct", tick: 128, callout: "Outside" }),
97
+ ], { mapName: "de_ancient", tickrate: 64 });
98
+
99
+ expect(deriveOpeningPressure(segments, {
100
+ mapName: "de_ancient",
101
+ side: "ct",
102
+ startTick: 0,
103
+ endTick: 192,
104
+ })).toEqual([expect.objectContaining({
105
+ playerIndex: 1,
106
+ callout: "Outside",
107
+ calloutLabel: "匪口",
108
+ kind: "deep",
109
+ opposingPositionGroupId: "t_outside",
110
+ })]);
111
+ });
112
+ });
@@ -0,0 +1,73 @@
1
+ import type { DefaultPositionSide, TacticalRegion } from "@cs2dak/maps";
2
+
3
+ export interface TacticalFrameSample {
4
+ tick: number;
5
+ playerIndex: number;
6
+ side: DefaultPositionSide;
7
+ alive: boolean;
8
+ callout: string | null;
9
+ }
10
+
11
+ export interface TacticalEvidenceRef {
12
+ type: "position" | "dwell" | "transition";
13
+ tick: number;
14
+ endTick?: number;
15
+ playerIndices?: number[];
16
+ callouts?: string[];
17
+ }
18
+
19
+ export interface PlayerTacticalSegment {
20
+ playerIndex: number;
21
+ side: DefaultPositionSide;
22
+ startTick: number;
23
+ endTick: number;
24
+ durationSec: number;
25
+ callout: string;
26
+ tendencies: readonly TacticalRegion[];
27
+ primaryRegion: TacticalRegion | null;
28
+ positionGroupId: string | null;
29
+ evidence: TacticalEvidenceRef;
30
+ }
31
+
32
+ export interface TacticalRegionCounts {
33
+ a: number;
34
+ b: number;
35
+ mid: number;
36
+ unknown: number;
37
+ }
38
+
39
+ export interface TacticalFormationSnapshot {
40
+ tick: number;
41
+ side: DefaultPositionSide;
42
+ regionCounts: TacticalRegionCounts;
43
+ positionGroupCounts: Record<string, number>;
44
+ playerLocations: Array<{
45
+ playerIndex: number;
46
+ callout: string | null;
47
+ primaryRegion: TacticalRegion | null;
48
+ positionGroupId: string | null;
49
+ }>;
50
+ }
51
+
52
+ export interface OpeningPattern {
53
+ side: DefaultPositionSide;
54
+ regionCounts: TacticalRegionCounts;
55
+ positionGroupCounts: Record<string, number>;
56
+ spread: "stacked" | "split" | "balanced" | "unknown";
57
+ coarseSignature: string;
58
+ detailedSignature: string;
59
+ evidence: TacticalEvidenceRef[];
60
+ }
61
+
62
+ export interface OpeningPressureEvent {
63
+ playerIndex: number;
64
+ side: DefaultPositionSide;
65
+ tick: number;
66
+ endTick: number;
67
+ callout: string;
68
+ calloutLabel: string;
69
+ primaryRegion: TacticalRegion | null;
70
+ kind: "forward" | "deep";
71
+ opposingPositionGroupId: string | null;
72
+ evidence: TacticalEvidenceRef;
73
+ }