@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,16 @@
1
+ /**
2
+ * 空间分析(严格重建)。两层:raw evidence(宽,复盘/shadow) → official gated(窄,进 RR)。
3
+ * 设计与边界见 docs/design/rr-model.md §3。
4
+ *
5
+ * SP1(已落地):RoundPhase 模型 + 位置标注地基。
6
+ * SP2/SP3(计划):official MapControl / UtilitySpatial gates + strategicIsolationDeathCredits。
7
+ */
8
+ export type { RoundPhase, RoundPhaseModel } from "./types.js";
9
+ export { OFFICIAL_EXCLUDED_PHASES, isOfficialScoringPhase } from "./types.js";
10
+ export { inferRoundPhases, phaseAtTick } from "./phase.js";
11
+ export type { SpatialAssets, AnnotatedSample } from "./annotate.js";
12
+ export { loadSpatialAssets, annotatePositions, groupSamplesByRoundTick } from "./annotate.js";
13
+ export type { OfficialMapControl } from "./mapcontrol.js";
14
+ export { buildOfficialMapControl } from "./mapcontrol.js";
15
+ export type { OfficialUtilitySpatial, UtilityWindow, GrenadeType } from "./utility.js";
16
+ export { buildOfficialUtilitySpatial, buildUtilityWindows } from "./utility.js";
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Replay } from "@cs2dak/contract";
3
+ import { loadSpatialAssets } from "./annotate.js";
4
+ import { buildOfficialMapControl } from "./mapcontrol.js";
5
+
6
+ function deltaArr(values: number[]): number[] {
7
+ const out: number[] = [];
8
+ let prev = 0;
9
+ for (const v of values) { out.push(v - prev); prev = v; }
10
+ return out;
11
+ }
12
+
13
+ function replayTracks(t1Places: string[], idxByPlace: Map<string, number>) {
14
+ return [
15
+ {
16
+ playerIndex: 0,
17
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
18
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
19
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
20
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
21
+ weapon: [-1, -1, -1, -1],
22
+ place: t1Places.map((p) => idxByPlace.get(p) ?? -1),
23
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
24
+ },
25
+ {
26
+ playerIndex: 1,
27
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
28
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
29
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
30
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
31
+ weapon: [-1, -1, -1, -1],
32
+ place: [idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!, idxByPlace.get("TSpawn")!],
33
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
34
+ },
35
+ {
36
+ playerIndex: 2,
37
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
38
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
39
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
40
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
41
+ weapon: [-1, -1, -1, -1],
42
+ place: [idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!, idxByPlace.get("BombsiteA")!],
43
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
44
+ },
45
+ {
46
+ playerIndex: 3,
47
+ x: deltaArr([0, 0, 0, 0]), y: deltaArr([0, 0, 0, 0]), z: deltaArr([0, 0, 0, 0]),
48
+ yaw: deltaArr([0, 0, 0, 0]), pitch: deltaArr([0, 0, 0, 0]),
49
+ hp: [100, 100, 100, 100], armor: [100, 100, 100, 100],
50
+ money: [800, 800, 800, 800], equipValue: [800, 800, 800, 800],
51
+ weapon: [-1, -1, -1, -1],
52
+ place: [idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!, idxByPlace.get("CTSpawn")!],
53
+ flash: [0, 0, 0, 0], flags: [1, 1, 1, 1],
54
+ },
55
+ ];
56
+ }
57
+
58
+ /** T1 独推 a_long(LongDoors→ARamp),T2 留 TSpawn,C1 守 BombsiteA(同线施压),C2 离线。 */
59
+ function makePkg(over: { kills?: unknown[] } = {}): DemoPackage {
60
+ const t1 = ["LongDoors", "LongA", "ARamp", "ARamp"];
61
+ const allPlaces = [...new Set([...t1, "TSpawn", "BombsiteA", "CTSpawn"])];
62
+ const idxByPlace = new Map(allPlaces.map((p, i) => [p, i]));
63
+ return {
64
+ match: { mapName: "de_dust2", tickrate: 64 },
65
+ players: [
66
+ { steamId64: "T1", teamKey: "teamA" },
67
+ { steamId64: "T2", teamKey: "teamA" },
68
+ { steamId64: "C1", teamKey: "teamB" },
69
+ { steamId64: "C2", teamKey: "teamB" },
70
+ ],
71
+ rounds: [{ roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 2000, teamASide: "t", teamBSide: "ct" }],
72
+ bombs: [],
73
+ kills: over.kills ?? [],
74
+ replay: {
75
+ meta: { sampleRate: 1, tickrate: 64, coordScale: 1, angleScale: 10 },
76
+ weaponDict: [],
77
+ placeDict: allPlaces,
78
+ rounds: [{
79
+ roundNumber: 1,
80
+ startTick: 150,
81
+ tickStep: 64,
82
+ frameCount: 4,
83
+ players: replayTracks(t1, idxByPlace) as unknown as Replay["rounds"][number]["players"],
84
+ projectiles: [],
85
+ }],
86
+ },
87
+ } as unknown as DemoPackage;
88
+ }
89
+
90
+ const untradedDeath = [{ roundNumber: 1, tick: 410, killerIndex: 2, victimIndex: 0, victimSteamId64: "T1", victimTeamKey: "teamA", killerSteamId64: "C1", killerTeamKey: "teamB", tradeDeath: false, weapon: "ak47", headshot: false }];
91
+
92
+ describe("buildOfficialMapControl", () => {
93
+ it("accumulates activeSoloPressureSeconds for a lone route pusher with enemy on the lane", () => {
94
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_dust2"));
95
+ expect(mc.get("T1")!.activeSoloPressureSeconds).toBe(4); // 4 个 1Hz 样本
96
+ expect(mc.get("T2")?.activeSoloPressureSeconds ?? 0).toBe(0); // 始终在 TSpawn,未上线
97
+ });
98
+
99
+ it("accumulates denial for the defending anchor and firstControl for the pusher", () => {
100
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_dust2"));
101
+ // C1 守 BombsiteA(a_long 末端)、T1 同线施压、无 plant → CT 防守 → C1 计 denial
102
+ expect(mc.get("C1")!.sidePhaseAwareDenialSeconds).toBeGreaterThan(0);
103
+ // T1 推进到 a_long 关键段(LongA idx3 ≥ ceil(6×0.4)=3)→ firstControl 一次
104
+ expect(mc.get("T1")!.firstMeaningfulControlEvents).toBe(1);
105
+ // T1 是进攻方,非防守 → 无 denial
106
+ expect(mc.get("T1")!.sidePhaseAwareDenialSeconds).toBe(0);
107
+ });
108
+
109
+ it("awards strategicIsolationDeaths credit for an untraded death after sustained solo pressure", () => {
110
+ const mc = buildOfficialMapControl(makePkg({ kills: untradedDeath }), loadSpatialAssets("de_dust2"));
111
+ // credit = clamp(0.25 + 0.10×4, 0, 1) = 0.65
112
+ expect(mc.get("T1")!.strategicIsolationDeaths).toBeCloseTo(0.65, 3);
113
+ });
114
+
115
+ it("gives no credit when the death was traded", () => {
116
+ const traded = [{ ...untradedDeath[0], tradeDeath: true }];
117
+ const mc = buildOfficialMapControl(makePkg({ kills: traded }), loadSpatialAssets("de_dust2"));
118
+ expect(mc.get("T1")?.strategicIsolationDeaths ?? 0).toBe(0);
119
+ });
120
+
121
+ it("gives no credit when there was no prior solo pressure (TSpawn camper)", () => {
122
+ const death = [{ roundNumber: 1, tick: 410, killerIndex: 2, victimIndex: 1, victimSteamId64: "T2", victimTeamKey: "teamA", killerSteamId64: "C1", killerTeamKey: "teamB", tradeDeath: false, weapon: "ak47", headshot: false }];
123
+ const mc = buildOfficialMapControl(makePkg({ kills: death }), loadSpatialAssets("de_dust2"));
124
+ expect(mc.get("T2")?.strategicIsolationDeaths ?? 0).toBe(0);
125
+ });
126
+
127
+ it("returns an empty map without route assets", () => {
128
+ const mc = buildOfficialMapControl(makePkg(), loadSpatialAssets("de_unknown"));
129
+ expect(mc.size).toBe(0);
130
+ });
131
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Official MapControl 派生(严格重建 SP2)。设计见 docs/design/rr-model.md §3.2/§3.3。
3
+ *
4
+ * 已落地 official 指标(callout/index-based MVP gates,输入 positions+routes+kills+phase):
5
+ * - activeSoloPressureSeconds 独占某动线 + 同线敌方施压 + official phase(cap 8s/round)
6
+ * - sidePhaseAwareDenialSeconds 防守方在敌方施压的动线上 holding × phaseFactor(cap 10s/round)
7
+ * - firstMeaningfulControlEvents T 首次推进到动线关键段(neutral/key index),每回合每动线一次
8
+ * - strategicIsolationDeaths 未交易死亡 × 死前窗口内 solo/denial/firstControl → 0~1 credit
9
+ *
10
+ * 这些 official 指标当前进 review/shadow 与 Trade 闭环(strategicIsolationDeaths),
11
+ * **不进 RR MapControl 评分账户**——后者需 rival-rating schema V2 + 权重(跨仓库 SP4)。
12
+ * 仍缺:denial 的 LOS/nav 距离精修、nonUtilityAssistedAdvanceUnits、UtilitySpatial
13
+ * actual-effect(需 zone 多边形标定)。
14
+ */
15
+ import type { DemoPackage } from "@cs2dak/contract";
16
+ import { routeIndex, type MapRoute, type MapRoutes } from "@cs2dak/maps";
17
+ import { createResolverFromPackage, type PlayerResolver } from "../resolve.js";
18
+ import { clamp, groupBy } from "../utils.js";
19
+ import { annotatePositions, type SpatialAssets, type AnnotatedSample } from "./annotate.js";
20
+ import { inferRoundPhases, phaseAtTick } from "./phase.js";
21
+ import { isOfficialScoringPhase, type RoundPhase, type RoundPhaseModel } from "./types.js";
22
+
23
+ export interface OfficialMapControl {
24
+ activeSoloPressureSeconds: number;
25
+ sidePhaseAwareDenialSeconds: number;
26
+ firstMeaningfulControlEvents: number;
27
+ strategicIsolationDeaths: number;
28
+ }
29
+
30
+ // —— gate 常数(doc §6/§8/§10/§22)——
31
+ const SOLO_PRESSURE_PER_ROUND_CAP = 8; // 秒
32
+ const DENIAL_PER_ROUND_CAP = 10; // 秒(已乘 phaseFactor)
33
+ const ISOLATION_WINDOW_SECONDS = 8; // 死前回看窗口 W
34
+ const ISOLATION_MIN_TRIGGER_SECONDS = 3; // 触发阈值 S
35
+ const DEFAULT_SAMPLE_RATE = 1; // 无 replay 时降级为 1Hz
36
+
37
+ type Team = string;
38
+
39
+ interface RoundEvidence {
40
+ /** tick → solo-pressure 玩家集合。 */
41
+ soloByTick: Map<number, Set<string>>;
42
+ /** tick → denial 玩家集合(用于 isolation 触发,未加权)。 */
43
+ denialByTick: Map<number, Set<string>>;
44
+ }
45
+
46
+ export function buildOfficialMapControl(
47
+ pkg: DemoPackage,
48
+ assets: SpatialAssets,
49
+ phases?: Map<number, RoundPhaseModel>,
50
+ ): Map<string, OfficialMapControl> {
51
+ const out = new Map<string, OfficialMapControl>();
52
+ if (!assets.routes) return out; // callout-based,需要动线资产
53
+ const routes = assets.routes;
54
+ const resolver = createResolverFromPackage(pkg);
55
+ const samples = annotatePositions(pkg, assets);
56
+ if (samples.length === 0) return out;
57
+ const sampleRate = pkg.replay?.meta.sampleRate ?? DEFAULT_SAMPLE_RATE;
58
+ const SAMPLE_SECONDS = 1 / sampleRate;
59
+ const phaseModels = phases ?? inferRoundPhases(pkg);
60
+ const teamByPlayer = new Map(pkg.players.map((p) => [p.steamId64, p.teamKey]));
61
+ const sideByTeam = new Map<number, { defendPre: Team | null; t: Team | null; ct: Team | null }>();
62
+ for (const round of pkg.rounds) {
63
+ const t = round.teamASide === "t" ? "teamA" : round.teamBSide === "t" ? "teamB" : null;
64
+ const ct = round.teamASide === "ct" ? "teamA" : round.teamBSide === "ct" ? "teamB" : null;
65
+ sideByTeam.set(round.roundNumber, { defendPre: ct, t, ct });
66
+ }
67
+ const plantByRound = plantTicks(pkg);
68
+
69
+ const pressureSeconds = new Map<string, number>();
70
+ const denialSeconds = new Map<string, number>();
71
+ const firstControlEvents = new Map<string, number>();
72
+ const evidenceByRound = new Map<number, RoundEvidence>();
73
+
74
+ const byRound = groupBy(samples, (s) => s.roundNumber);
75
+ for (const [roundNumber, rows] of byRound) {
76
+ const phase = phaseModels.get(roundNumber);
77
+ const sides = sideByTeam.get(roundNumber) ?? { defendPre: null, t: null, ct: null };
78
+ const plantTick = plantByRound.get(roundNumber) ?? null;
79
+ const evidence: RoundEvidence = { soloByTick: new Map(), denialByTick: new Map() };
80
+
81
+ const perRoundSolo = new Map<string, number>();
82
+ const perRoundDenial = new Map<string, number>();
83
+ const takenRoutes = new Set<string>(); // firstControl 去重:每回合每动线一次
84
+
85
+ for (const [tick, tickRows] of groupBy(rows, (r) => r.tick)) {
86
+ const ph: RoundPhase = phase ? phaseAtTick(phase, tick) : "default";
87
+ if (!isOfficialScoringPhase(ph)) continue;
88
+ const presence = routePresence(tickRows, routes, teamByPlayer);
89
+ const defenderTeam = plantTick != null && tick >= plantTick ? sides.t : sides.ct;
90
+
91
+ const solo = new Set<string>();
92
+ const denial = new Set<string>();
93
+ for (const [route, byTeam] of presence) {
94
+ deriveSoloAndDenial(byTeam, defenderTeam, ph, solo, denial, perRoundSolo, perRoundDenial, SAMPLE_SECONDS);
95
+ // firstMeaningfulControlEvents:T 首次到达关键段(neutral/key index)
96
+ recordFirstControl(route, byTeam, sides.t, takenRoutes, firstControlEvents, ph);
97
+ }
98
+ if (solo.size) evidence.soloByTick.set(tick, solo);
99
+ if (denial.size) evidence.denialByTick.set(tick, denial);
100
+ }
101
+
102
+ for (const [id, secs] of perRoundSolo) {
103
+ pressureSeconds.set(id, (pressureSeconds.get(id) ?? 0) + Math.min(secs, SOLO_PRESSURE_PER_ROUND_CAP));
104
+ }
105
+ for (const [id, secs] of perRoundDenial) {
106
+ denialSeconds.set(id, (denialSeconds.get(id) ?? 0) + Math.min(secs, DENIAL_PER_ROUND_CAP));
107
+ }
108
+ evidenceByRound.set(roundNumber, evidence);
109
+ }
110
+
111
+ const credits = buildIsolationCredits(pkg, evidenceByRound, phaseModels, SAMPLE_SECONDS, resolver);
112
+
113
+ const ids = new Set<string>([
114
+ ...pressureSeconds.keys(),
115
+ ...denialSeconds.keys(),
116
+ ...firstControlEvents.keys(),
117
+ ...credits.keys(),
118
+ ]);
119
+ for (const id of ids) {
120
+ out.set(id, {
121
+ activeSoloPressureSeconds: round3(pressureSeconds.get(id) ?? 0),
122
+ sidePhaseAwareDenialSeconds: round3(denialSeconds.get(id) ?? 0),
123
+ firstMeaningfulControlEvents: firstControlEvents.get(id) ?? 0,
124
+ strategicIsolationDeaths: round3(credits.get(id) ?? 0),
125
+ });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** 每动线 → 每队在线(routeIndex≥1)成员。 */
131
+ function routePresence(
132
+ tickRows: AnnotatedSample[],
133
+ routes: MapRoutes,
134
+ teamByPlayer: Map<string, string>,
135
+ ): Map<MapRoute, Map<Team, { id: string; index: number }[]>> {
136
+ const out = new Map<MapRoute, Map<Team, { id: string; index: number }[]>>();
137
+ for (const route of routes.routes) {
138
+ const byTeam = new Map<Team, { id: string; index: number }[]>();
139
+ for (const row of tickRows) {
140
+ if (!row.alive) continue;
141
+ const idx = routeIndex(route, row.callout);
142
+ if (idx < 1) continue;
143
+ const team = teamByPlayer.get(row.steamId64) ?? row.teamKey;
144
+ const arr = byTeam.get(team) ?? [];
145
+ arr.push({ id: row.steamId64, index: idx });
146
+ byTeam.set(team, arr);
147
+ }
148
+ if (byTeam.size > 0) out.set(route, byTeam);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function deriveSoloAndDenial(
154
+ byTeam: Map<Team, { id: string; index: number }[]>,
155
+ defenderTeam: Team | null,
156
+ phase: RoundPhase,
157
+ solo: Set<string>,
158
+ denial: Set<string>,
159
+ perRoundSolo: Map<string, number>,
160
+ perRoundDenial: Map<string, number>,
161
+ sampleSecs: number,
162
+ ): void {
163
+ if (byTeam.size < 2) return; // 需双方都在该线(敌方施压)
164
+ for (const [team, members] of byTeam) {
165
+ const enemyPresent = [...byTeam].some(([t, m]) => t !== team && m.length >= 1);
166
+ if (!enemyPresent) continue;
167
+ // solo:本队仅此一人在该线
168
+ if (members.length === 1 && !solo.has(members[0]!.id)) {
169
+ solo.add(members[0]!.id);
170
+ perRoundSolo.set(members[0]!.id, (perRoundSolo.get(members[0]!.id) ?? 0) + sampleSecs);
171
+ }
172
+ // denial:防守方在敌方施压的动线上 holding(× phaseFactor)
173
+ if (defenderTeam != null && team === defenderTeam) {
174
+ const factor = phaseFactor(phase);
175
+ if (factor > 0) {
176
+ for (const m of members) {
177
+ if (denial.has(m.id)) continue;
178
+ denial.add(m.id);
179
+ perRoundDenial.set(m.id, (perRoundDenial.get(m.id) ?? 0) + sampleSecs * factor);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /** T 首次推进到动线关键段:index ≥ max(1, ceil(len×0.4)),每回合每动线记一次,归 frontier T。 */
187
+ function recordFirstControl(
188
+ route: MapRoute,
189
+ byTeam: Map<Team, { id: string; index: number }[]>,
190
+ tTeam: Team | null,
191
+ taken: Set<string>,
192
+ firstControlEvents: Map<string, number>,
193
+ phase: RoundPhase,
194
+ ): void {
195
+ if (tTeam == null || taken.has(route.id)) return;
196
+ if (phase !== "default" && phase !== "take" && phase !== "execute") return;
197
+ const tMembers = byTeam.get(tTeam);
198
+ if (!tMembers) return;
199
+ const threshold = Math.max(1, Math.ceil(route.zones.length * 0.4));
200
+ const frontier = tMembers.filter((m) => m.index >= threshold).sort((a, b) => b.index - a.index)[0];
201
+ if (!frontier) return;
202
+ taken.add(route.id);
203
+ firstControlEvents.set(frontier.id, (firstControlEvents.get(frontier.id) ?? 0) + 1);
204
+ }
205
+
206
+ function buildIsolationCredits(
207
+ pkg: DemoPackage,
208
+ evidenceByRound: Map<number, RoundEvidence>,
209
+ phases: Map<number, RoundPhaseModel>,
210
+ sampleSecs: number,
211
+ resolver: PlayerResolver,
212
+ ): Map<string, number> {
213
+ const tickrate = pkg.match?.tickrate ?? pkg.manifest?.tickrate ?? 64;
214
+ const windowTicks = ISOLATION_WINDOW_SECONDS * tickrate;
215
+ const credits = new Map<string, number>();
216
+
217
+ for (const kill of pkg.kills) {
218
+ const killerPlayer = resolver.byIndexOrNull(kill.killerIndex);
219
+ const victimPlayer = resolver.byIndexOrNull(kill.victimIndex);
220
+ if (!victimPlayer) continue;
221
+ if (killerPlayer && killerPlayer.teamKey === victimPlayer.teamKey) continue;
222
+ if ((kill as { tradeDeath?: boolean }).tradeDeath) continue;
223
+ const victim = victimPlayer.steamId64;
224
+ if (!victim) continue;
225
+ const phase = phases.get(kill.roundNumber);
226
+ if (phase && !isOfficialScoringPhase(phaseAtTick(phase, kill.tick))) continue;
227
+ const evidence = evidenceByRound.get(kill.roundNumber);
228
+ if (!evidence) continue;
229
+
230
+ const lo = kill.tick - windowTicks;
231
+ const pressure = secondsInWindow(evidence.soloByTick, victim, lo, kill.tick, sampleSecs);
232
+ const denial = secondsInWindow(evidence.denialByTick, victim, lo, kill.tick, sampleSecs);
233
+ if (pressure < ISOLATION_MIN_TRIGGER_SECONDS && denial < ISOLATION_MIN_TRIGGER_SECONDS) continue;
234
+
235
+ // credit = clamp(0.25 + 0.10×pressure + 0.10×denial, 0, 1)(doc §10.3 子集)
236
+ const credit = clamp(0.25 + 0.1 * pressure + 0.1 * denial, 0, 1);
237
+ credits.set(victim, (credits.get(victim) ?? 0) + credit);
238
+ }
239
+ return credits;
240
+ }
241
+
242
+ function secondsInWindow(byTick: Map<number, Set<string>>, id: string, lo: number, hi: number, sampleSecs: number): number {
243
+ let secs = 0;
244
+ for (const [tick, set] of byTick) {
245
+ if (tick > hi || tick < lo) continue;
246
+ if (set.has(id)) secs += sampleSecs;
247
+ }
248
+ return secs;
249
+ }
250
+
251
+ function phaseFactor(phase: RoundPhase): number {
252
+ switch (phase) {
253
+ case "execute":
254
+ case "postPlant":
255
+ case "retake":
256
+ case "clutch":
257
+ return 1.2;
258
+ case "take":
259
+ return 1.0;
260
+ case "default":
261
+ return 0.5;
262
+ default:
263
+ return 0;
264
+ }
265
+ }
266
+
267
+ function plantTicks(pkg: DemoPackage): Map<number, number> {
268
+ const out = new Map<number, number>();
269
+ for (const bomb of pkg.bombs) {
270
+ if (bomb.type === "planted" && !out.has(bomb.roundNumber)) out.set(bomb.roundNumber, bomb.tick);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function round3(v: number): number {
276
+ return Math.round(v * 1000) / 1000;
277
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage, Replay } from "@cs2dak/contract";
3
+ import { inferRoundPhases, phaseAtTick } from "./phase.js";
4
+ import { isOfficialScoringPhase, OFFICIAL_EXCLUDED_PHASES } from "./types.js";
5
+
6
+ function deltaArr(values: number[]): number[] {
7
+ const out: number[] = [];
8
+ let prev = 0;
9
+ for (const v of values) { out.push(v - prev); prev = v; }
10
+ return out;
11
+ }
12
+
13
+ /** Build a single-frame replay round with the given player tracks. */
14
+ function replayFrame(
15
+ roundNumber: number,
16
+ tick: number,
17
+ tracks: { playerIdx: number; placeIdx: number }[],
18
+ ): Replay["rounds"][number] {
19
+ return {
20
+ roundNumber,
21
+ startTick: tick,
22
+ tickStep: 1,
23
+ frameCount: 1,
24
+ players: tracks.map((t) => ({
25
+ playerIndex: t.playerIdx,
26
+ x: deltaArr([0]), y: deltaArr([0]), z: deltaArr([0]),
27
+ yaw: deltaArr([0]), pitch: deltaArr([0]),
28
+ hp: [100], armor: [100], money: [800], equipValue: [800],
29
+ place: [t.placeIdx], flash: [0], flags: [1], weapon: [-1], grenades: [[]],
30
+ })),
31
+ projectiles: [],
32
+ };
33
+ }
34
+
35
+ /** Build a two-frame replay round for take+execute detection at different ticks. */
36
+ function replayTwoFrames(
37
+ roundNumber: number,
38
+ frame1: { tick: number; tracks: { playerIdx: number; placeIdx: number }[] },
39
+ frame2: { tick: number; tracks: { playerIdx: number; placeIdx: number }[] },
40
+ ): Replay["rounds"][number] {
41
+ const tickStep = frame2.tick - frame1.tick;
42
+ const nPlayers = Math.max(frame1.tracks.length, frame2.tracks.length);
43
+ const players = [];
44
+ for (let i = 0; i < nPlayers; i++) {
45
+ const t1 = frame1.tracks.find((t) => t.playerIdx === i);
46
+ const t2 = frame2.tracks.find((t) => t.playerIdx === i);
47
+ const place1 = t1?.placeIdx ?? -1;
48
+ const place2 = t2?.placeIdx ?? -1;
49
+ players.push({
50
+ playerIndex: i,
51
+ x: deltaArr([0, 0]), y: deltaArr([0, 0]), z: deltaArr([0, 0]),
52
+ yaw: deltaArr([0, 0]), pitch: deltaArr([0, 0]),
53
+ hp: [100, 100], armor: [100, 100], money: [800, 800], equipValue: [800, 800],
54
+ place: [place1, place2], flash: [0, 0], flags: [1, 1], weapon: [-1, -1], grenades: [[], []],
55
+ });
56
+ }
57
+ return { roundNumber, startTick: frame1.tick, tickStep, frameCount: 2, players, projectiles: [] };
58
+ }
59
+
60
+ /** 最小 DemoPackage:phase 推导只读 match/players/rounds/bombs/kills。 */
61
+ function makePkg(over: Partial<{
62
+ bombs: unknown[];
63
+ kills: unknown[];
64
+ replay: unknown;
65
+ }> = {}): DemoPackage {
66
+ return {
67
+ match: { mapName: "de_dust2", tickrate: 64 },
68
+ players: [
69
+ { steamId64: "T1", teamKey: "teamA" },
70
+ { steamId64: "T2", teamKey: "teamA" },
71
+ { steamId64: "C1", teamKey: "teamB" },
72
+ { steamId64: "C2", teamKey: "teamB" },
73
+ ],
74
+ rounds: [
75
+ { roundNumber: 1, startTick: 1, freezeEndTick: 100, endTick: 1000, teamASide: "t", teamBSide: "ct" },
76
+ ],
77
+ bombs: over.bombs ?? [],
78
+ kills: over.kills ?? [],
79
+ replay: over.replay,
80
+ } as unknown as DemoPackage;
81
+ }
82
+
83
+ describe("inferRoundPhases / phaseAtTick", () => {
84
+ it("marks the freeze window then default", () => {
85
+ const m = inferRoundPhases(makePkg()).get(1)!;
86
+ expect(phaseAtTick(m, 1)).toBe("freeze");
87
+ expect(phaseAtTick(m, 99)).toBe("freeze");
88
+ expect(phaseAtTick(m, 100)).toBe("default");
89
+ expect(phaseAtTick(m, 500)).toBe("default");
90
+ });
91
+
92
+ it("enters postPlant from the plant tick", () => {
93
+ const m = inferRoundPhases(makePkg({
94
+ bombs: [{ roundNumber: 1, tick: 300, type: "planted", actorTeamKey: "teamA" }],
95
+ })).get(1)!;
96
+ expect(m.plantTick).toBe(300);
97
+ expect(phaseAtTick(m, 299)).not.toBe("postPlant");
98
+ expect(phaseAtTick(m, 300)).toBe("postPlant");
99
+ expect(phaseAtTick(m, 800)).toBe("postPlant");
100
+ });
101
+
102
+ it("enters clutch when a side drops to one alive", () => {
103
+ // teamB starts at 2; one kill on teamB → 1 alive → clutch
104
+ const m = inferRoundPhases(makePkg({
105
+ kills: [{ roundNumber: 1, tick: 250, victimTeamKey: "teamB", victimIndex: 2, killerIndex: 0, weapon: "ak47", headshot: false }],
106
+ })).get(1)!;
107
+ expect(m.clutchStartTick).toBe(250);
108
+ expect(phaseAtTick(m, 249)).toBe("default");
109
+ expect(phaseAtTick(m, 250)).toBe("clutch");
110
+ });
111
+
112
+ it("clutch overrides postPlant in the same window", () => {
113
+ const m = inferRoundPhases(makePkg({
114
+ bombs: [{ roundNumber: 1, tick: 300, type: "planted", actorTeamKey: "teamA" }],
115
+ kills: [{ roundNumber: 1, tick: 350, victimTeamKey: "teamA", victimIndex: 0, killerIndex: 2, weapon: "ak47", headshot: false }],
116
+ })).get(1)!;
117
+ expect(phaseAtTick(m, 320)).toBe("postPlant");
118
+ expect(phaseAtTick(m, 360)).toBe("clutch");
119
+ });
120
+
121
+ it("detects take when a T advances beyond spawn (routeIndex >= 1)", () => {
122
+ const placeDict = ["TSpawn", "OutsideLong"];
123
+ const replay = {
124
+ meta: { sampleRate: 1, tickrate: 64, coordScale: 1, angleScale: 10 },
125
+ weaponDict: [], placeDict,
126
+ rounds: [replayFrame(1, 150, [
127
+ { playerIdx: 0, placeIdx: 1 }, // T1 → OutsideLong
128
+ { playerIdx: 1, placeIdx: 0 }, // T2 → TSpawn
129
+ ])],
130
+ };
131
+ const m = inferRoundPhases(makePkg({ replay })).get(1)!;
132
+ expect(m.hasPositions).toBe(true);
133
+ expect(m.hasRoutes).toBe(true);
134
+ expect(m.takeTick).toBe(150);
135
+ expect(phaseAtTick(m, 150)).toBe("take");
136
+ });
137
+
138
+ it("detects execute when two T near the site entry (routeIndex >= len-2)", () => {
139
+ // a_long: ARamp=4, BombsiteA=5 → both >= len-2 (=4)
140
+ const placeDict = ["TSpawn", "OutsideLong", "ARamp", "BombsiteA"];
141
+ const replay = {
142
+ meta: { sampleRate: 1, tickrate: 64, coordScale: 1, angleScale: 10 },
143
+ weaponDict: [], placeDict,
144
+ rounds: [replayTwoFrames(1,
145
+ { tick: 140, tracks: [{ playerIdx: 0, placeIdx: 1 }] }, // T1 → OutsideLong
146
+ { tick: 260, tracks: [{ playerIdx: 0, placeIdx: 2 }, { playerIdx: 1, placeIdx: 3 }] }, // T1→ARamp, T2→BombsiteA
147
+ )],
148
+ };
149
+ const m = inferRoundPhases(makePkg({ replay })).get(1)!;
150
+ expect(m.takeTick).toBe(140);
151
+ expect(m.executeTick).toBe(260);
152
+ expect(phaseAtTick(m, 200)).toBe("take");
153
+ expect(phaseAtTick(m, 260)).toBe("execute");
154
+ });
155
+
156
+ it("degrades take/execute to null without positions", () => {
157
+ const m = inferRoundPhases(makePkg()).get(1)!;
158
+ expect(m.hasPositions).toBe(false);
159
+ expect(m.takeTick).toBeNull();
160
+ expect(m.executeTick).toBeNull();
161
+ });
162
+
163
+ it("excludes freeze/save/exit from official scoring", () => {
164
+ expect(isOfficialScoringPhase("freeze")).toBe(false);
165
+ expect(isOfficialScoringPhase("save")).toBe(false);
166
+ expect(isOfficialScoringPhase("exit")).toBe(false);
167
+ expect(isOfficialScoringPhase("take")).toBe(true);
168
+ expect(isOfficialScoringPhase("postPlant")).toBe(true);
169
+ expect(OFFICIAL_EXCLUDED_PHASES.has("clutch")).toBe(false);
170
+ });
171
+ });