@cs2dak/core 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/package.json +4 -3
- package/src/duel-window.ts +199 -0
- package/src/duels.test.ts +370 -0
- package/src/duels.ts +538 -0
- package/src/fixture-invariants.test.ts +40 -0
- package/src/index.test.ts +46 -53
- package/src/index.ts +25 -8
- package/src/loader.ts +31 -17
- package/src/map-intelligence/awp.test.ts +57 -0
- package/src/map-intelligence/awp.ts +45 -0
- package/src/map-intelligence/ct-rotation.test.ts +149 -0
- package/src/map-intelligence/ct-rotation.ts +324 -0
- package/src/map-intelligence/index.ts +74 -0
- package/src/map-intelligence/map-intelligence.test.ts +62 -0
- package/src/map-intelligence/opening-window.ts +19 -0
- package/src/map-intelligence/player-position.test.ts +28 -0
- package/src/map-intelligence/player-position.ts +250 -0
- package/src/map-intelligence/spatial.test.ts +25 -0
- package/src/map-intelligence/spatial.ts +112 -0
- package/src/map-intelligence/team-awp-round.ts +60 -0
- package/src/map-intelligence/team-shape.test.ts +43 -0
- package/src/map-intelligence/team-shape.ts +58 -0
- package/src/mechanics.test.ts +375 -0
- package/src/mechanics.ts +628 -0
- package/src/normalize.ts +27 -1
- package/src/qa.test.ts +115 -0
- package/src/qa.ts +51 -15
- package/src/radar-field.test.ts +79 -0
- package/src/radar-field.ts +395 -0
- package/src/resolve.test.ts +100 -0
- package/src/resolve.ts +69 -0
- package/src/scoreboard.ts +68 -38
- package/src/signals.ts +149 -112
- package/src/spatial/annotate.test.ts +133 -0
- package/src/spatial/annotate.ts +163 -0
- package/src/spatial/index.ts +16 -0
- package/src/spatial/mapcontrol.test.ts +131 -0
- package/src/spatial/mapcontrol.ts +277 -0
- package/src/spatial/phase.test.ts +171 -0
- package/src/spatial/phase.ts +179 -0
- package/src/spatial/trade-closure.test.ts +38 -0
- package/src/spatial/types.ts +56 -0
- package/src/spatial/utility-geometry.test.ts +88 -0
- package/src/spatial/utility-geometry.ts +167 -0
- package/src/spatial/utility.integration.test.ts +38 -0
- package/src/spatial/utility.test.ts +120 -0
- package/src/spatial/utility.ts +399 -0
- package/src/tactics/formations.ts +151 -0
- package/src/tactics/index.ts +16 -0
- package/src/tactics/replay-round-context.ts +96 -0
- package/src/tactics/round-facts.ts +406 -0
- package/src/tactics/segments.ts +68 -0
- package/src/tactics/tactics.test.ts +112 -0
- package/src/tactics/types.ts +73 -0
- package/src/timeline.ts +73 -52
- package/src/utility-facts.test.ts +55 -0
- package/src/utility-facts.ts +137 -0
- package/src/utils.ts +27 -25
- package/src/weapon-highlights.ts +4 -4
- package/src/economy.test.ts +0 -37
- package/src/economy.ts +0 -57
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合阶段推导(严格重建 SP1)。设计见 docs/design/rr-model.md §3.1。
|
|
3
|
+
*
|
|
4
|
+
* MVP 确定性派生 freeze / default / take / execute / postPlant / clutch:
|
|
5
|
+
* - freeze : [startTick, freezeEndTick)
|
|
6
|
+
* - postPlant : [plantTick, endTick)(双方通用窗口;side-aware gate 自行区分守包/retake)
|
|
7
|
+
* - clutch : 一方仅剩 1 人存活(另一方 ≥ 1)起,覆盖战斗类阶段
|
|
8
|
+
* - take : T 首次推进出生区外(routeIndex ≥ 1)
|
|
9
|
+
* - execute : 某条动线上 ≥ 2 名 T 逼近包点入口(routeIndex ≥ len-2)
|
|
10
|
+
*
|
|
11
|
+
* 无 positions / routes 时 take/execute 为 null(降级为 default),并在
|
|
12
|
+
* RoundPhaseModel.hasPositions/hasRoutes 标注,供 evidenceQuality 使用。
|
|
13
|
+
*/
|
|
14
|
+
import type { DemoPackage } from "@cs2dak/contract";
|
|
15
|
+
import { getMapRoutes, routeIndex, type MapRoutes } from "@cs2dak/maps";
|
|
16
|
+
import { createResolverFromPackage } from "../resolve.js";
|
|
17
|
+
import { groupBy } from "../utils.js";
|
|
18
|
+
import type { RoundPhase, RoundPhaseModel } from "./types.js";
|
|
19
|
+
|
|
20
|
+
type Round = DemoPackage["rounds"][number];
|
|
21
|
+
type PositionRow = { roundNumber: number; tick: number; steamId64: string; lastPlaceName?: string | null };
|
|
22
|
+
type Kill = DemoPackage["kills"][number];
|
|
23
|
+
|
|
24
|
+
export function inferRoundPhases(pkg: DemoPackage): Map<number, RoundPhaseModel> {
|
|
25
|
+
const routes = getMapRoutes(pkg.match?.mapName ?? "");
|
|
26
|
+
// Build positions from replay 8Hz stream (v3 replaces positions-1s)
|
|
27
|
+
const positions = buildPositionRows(pkg);
|
|
28
|
+
const hasPositions = positions.length > 0;
|
|
29
|
+
const hasRoutes = routes != null;
|
|
30
|
+
|
|
31
|
+
const teamByPlayer = new Map(pkg.players.map((p) => [p.steamId64, p.teamKey]));
|
|
32
|
+
const teamSize = countByTeam(pkg);
|
|
33
|
+
|
|
34
|
+
const posByRound = groupBy(positions, (row) => row.roundNumber);
|
|
35
|
+
const killsByRound = groupBy(pkg.kills, (k) => k.roundNumber);
|
|
36
|
+
const playerTeams = pkg.players.map((p) => p.teamKey);
|
|
37
|
+
const plantByRound = new Map<number, number>();
|
|
38
|
+
for (const bomb of pkg.bombs) {
|
|
39
|
+
if (bomb.type === "planted" && !plantByRound.has(bomb.roundNumber)) {
|
|
40
|
+
plantByRound.set(bomb.roundNumber, bomb.tick);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const out = new Map<number, RoundPhaseModel>();
|
|
45
|
+
for (const round of pkg.rounds) {
|
|
46
|
+
const tTeam = round.teamASide === "t" ? "teamA" : round.teamBSide === "t" ? "teamB" : null;
|
|
47
|
+
const plantTick = plantByRound.get(round.roundNumber) ?? null;
|
|
48
|
+
const clutchStartTick = computeClutchStart(killsByRound.get(round.roundNumber) ?? [], teamSize, playerTeams);
|
|
49
|
+
const { takeTick, executeTick } = computeTakeExecute(
|
|
50
|
+
round,
|
|
51
|
+
tTeam,
|
|
52
|
+
routes,
|
|
53
|
+
posByRound.get(round.roundNumber) ?? [],
|
|
54
|
+
teamByPlayer,
|
|
55
|
+
plantTick,
|
|
56
|
+
);
|
|
57
|
+
out.set(round.roundNumber, {
|
|
58
|
+
roundNumber: round.roundNumber,
|
|
59
|
+
startTick: round.startTick,
|
|
60
|
+
freezeEndTick: round.freezeEndTick,
|
|
61
|
+
endTick: round.endTick,
|
|
62
|
+
plantTick,
|
|
63
|
+
takeTick,
|
|
64
|
+
executeTick,
|
|
65
|
+
clutchStartTick,
|
|
66
|
+
hasPositions,
|
|
67
|
+
hasRoutes,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function phaseAtTick(model: RoundPhaseModel, tick: number): RoundPhase {
|
|
74
|
+
if (tick < model.freezeEndTick) return "freeze";
|
|
75
|
+
if (model.clutchStartTick != null && tick >= model.clutchStartTick) return "clutch";
|
|
76
|
+
if (model.plantTick != null && tick >= model.plantTick) return "postPlant";
|
|
77
|
+
if (model.executeTick != null && tick >= model.executeTick) return "execute";
|
|
78
|
+
if (model.takeTick != null && tick >= model.takeTick) return "take";
|
|
79
|
+
return "default";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function countByTeam(pkg: DemoPackage): { teamA: number; teamB: number } {
|
|
83
|
+
let teamA = 0;
|
|
84
|
+
let teamB = 0;
|
|
85
|
+
for (const p of pkg.players) {
|
|
86
|
+
if (p.teamKey === "teamA") teamA += 1;
|
|
87
|
+
else if (p.teamKey === "teamB") teamB += 1;
|
|
88
|
+
}
|
|
89
|
+
return { teamA, teamB };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 一方存活降到 1(另一方 ≥ 1)的首个 tick。 */
|
|
93
|
+
function computeClutchStart(kills: Kill[], teamSize: { teamA: number; teamB: number }, playerTeams: string[]): number | null {
|
|
94
|
+
let aliveA = teamSize.teamA;
|
|
95
|
+
let aliveB = teamSize.teamB;
|
|
96
|
+
if (aliveA === 0 || aliveB === 0) return null;
|
|
97
|
+
for (const kill of [...kills].sort((a, b) => a.tick - b.tick)) {
|
|
98
|
+
const victimTeam = playerTeams[kill.victimIndex];
|
|
99
|
+
if (victimTeam === "teamA") aliveA = Math.max(0, aliveA - 1);
|
|
100
|
+
else if (victimTeam === "teamB") aliveB = Math.max(0, aliveB - 1);
|
|
101
|
+
if ((aliveA === 1 && aliveB >= 1) || (aliveB === 1 && aliveA >= 1)) {
|
|
102
|
+
return kill.tick;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function computeTakeExecute(
|
|
109
|
+
round: Round,
|
|
110
|
+
tTeam: "teamA" | "teamB" | null,
|
|
111
|
+
routes: MapRoutes | null,
|
|
112
|
+
rows: PositionRow[],
|
|
113
|
+
teamByPlayer: Map<string, string>,
|
|
114
|
+
plantTick: number | null,
|
|
115
|
+
): { takeTick: number | null; executeTick: number | null } {
|
|
116
|
+
if (!routes || !tTeam || rows.length === 0) return { takeTick: null, executeTick: null };
|
|
117
|
+
|
|
118
|
+
// 按 tick 聚合本回合 freeze 后、plant 前的 T 方 callout。
|
|
119
|
+
const placesByTick = new Map<number, string[]>();
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (teamByPlayer.get(row.steamId64) !== tTeam) continue;
|
|
122
|
+
if (row.tick < round.freezeEndTick) continue;
|
|
123
|
+
if (plantTick != null && row.tick >= plantTick) continue;
|
|
124
|
+
if (!row.lastPlaceName) continue;
|
|
125
|
+
const arr = placesByTick.get(row.tick) ?? [];
|
|
126
|
+
arr.push(row.lastPlaceName);
|
|
127
|
+
placesByTick.set(row.tick, arr);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let takeTick: number | null = null;
|
|
131
|
+
let executeTick: number | null = null;
|
|
132
|
+
for (const tick of [...placesByTick.keys()].sort((a, b) => a - b)) {
|
|
133
|
+
const places = placesByTick.get(tick)!;
|
|
134
|
+
let advanced = false;
|
|
135
|
+
for (const route of routes.routes) {
|
|
136
|
+
const threshold = Math.max(1, route.zones.length - 2);
|
|
137
|
+
let nearSite = 0;
|
|
138
|
+
for (const place of places) {
|
|
139
|
+
const idx = routeIndex(route, place);
|
|
140
|
+
if (idx >= 1) advanced = true;
|
|
141
|
+
if (idx >= threshold) nearSite += 1;
|
|
142
|
+
}
|
|
143
|
+
if (nearSite >= 2 && executeTick == null) executeTick = tick;
|
|
144
|
+
}
|
|
145
|
+
if (advanced && takeTick == null) takeTick = tick;
|
|
146
|
+
if (takeTick != null && executeTick != null) break;
|
|
147
|
+
}
|
|
148
|
+
// execute 蕴含 take
|
|
149
|
+
if (executeTick != null && (takeTick == null || takeTick > executeTick)) takeTick = executeTick;
|
|
150
|
+
return { takeTick, executeTick };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 从 replay 8Hz 流提取回合阶段推导所需的简化位置行。 */
|
|
154
|
+
function buildPositionRows(pkg: DemoPackage): PositionRow[] {
|
|
155
|
+
const resolver = createResolverFromPackage(pkg);
|
|
156
|
+
const replay = pkg.replay;
|
|
157
|
+
if (!replay) return [];
|
|
158
|
+
const placeDict = replay.placeDict ?? [];
|
|
159
|
+
const out: PositionRow[] = [];
|
|
160
|
+
for (const round of replay.rounds) {
|
|
161
|
+
const tickStep = round.tickStep;
|
|
162
|
+
for (const track of round.players) {
|
|
163
|
+
const player = resolver.byIndexOrNull(track.playerIndex);
|
|
164
|
+
if (!player) continue;
|
|
165
|
+
for (let i = 0; i < track.x.length; i++) {
|
|
166
|
+
const placeIdx = track.place[i] ?? -1;
|
|
167
|
+
const lastPlaceName =
|
|
168
|
+
placeIdx >= 0 && placeIdx < placeDict.length ? placeDict[placeIdx] : null;
|
|
169
|
+
out.push({
|
|
170
|
+
roundNumber: round.roundNumber,
|
|
171
|
+
tick: round.startTick + i * tickStep,
|
|
172
|
+
steamId64: player.steamId64,
|
|
173
|
+
lastPlaceName,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { loadDemoPackageFromZip } from "../loader.js";
|
|
5
|
+
import { deriveRRSignals } from "../signals.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SP2 Trade 闭环端到端:de_ancient fixture 含 replay + 现役图动线,
|
|
9
|
+
* 故 strategicIsolationDeaths 应可观测(非 null),接入 rival-rating 的
|
|
10
|
+
* effectiveUntradedDeaths(rr-model.md §3.3)。
|
|
11
|
+
*/
|
|
12
|
+
describe("strategicIsolationDeaths wiring (de_ancient fixture)", () => {
|
|
13
|
+
it("derives an observable (non-null, >= 0) credit for every player", async () => {
|
|
14
|
+
const zip = await readFile(
|
|
15
|
+
fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)),
|
|
16
|
+
);
|
|
17
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
18
|
+
const signals = deriveRRSignals(pkg);
|
|
19
|
+
|
|
20
|
+
expect(signals).toHaveLength(10);
|
|
21
|
+
for (const s of signals) {
|
|
22
|
+
expect(s.trade.strategicIsolationDeaths).not.toBeNull();
|
|
23
|
+
expect(s.trade.strategicIsolationDeaths as number).toBeGreaterThanOrEqual(0);
|
|
24
|
+
// credit 不应超过该选手的死亡数(抵扣项语义上界)
|
|
25
|
+
expect(s.trade.strategicIsolationDeaths as number).toBeLessThanOrEqual(s.trade.deaths);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns null strategicIsolationDeaths when replay is absent (unobservable)", async () => {
|
|
30
|
+
const zip = await readFile(
|
|
31
|
+
fileURLToPath(new URL("../../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url)),
|
|
32
|
+
);
|
|
33
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
34
|
+
const { replay: _, ...stripped } = pkg;
|
|
35
|
+
const signals = deriveRRSignals(stripped);
|
|
36
|
+
for (const s of signals) expect(s.trade.strategicIsolationDeaths).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|