@cs2dak/presentation 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 +8 -7
- package/src/double-awp.test.ts +29 -0
- package/src/double-awp.ts +50 -0
- package/src/duel.test.ts +190 -0
- package/src/duel.ts +359 -0
- package/src/findings.test.ts +62 -0
- package/src/findings.ts +174 -0
- package/src/index.test.ts +41 -8
- package/src/index.ts +90 -6
- package/src/insights.test.ts +522 -0
- package/src/insights.ts +1151 -0
- package/src/leaderboard.test.ts +48 -74
- package/src/map-roles.test.ts +131 -0
- package/src/map-roles.ts +219 -0
- package/src/player-map-pool.ts +28 -0
- package/src/player.test.ts +139 -110
- package/src/player.ts +66 -28
- package/src/radar-field.ts +121 -0
- package/src/replay-clock.test.ts +33 -0
- package/src/replay-clock.ts +61 -0
- package/src/season-metrics.ts +5 -0
- package/src/season-validation.test.ts +28 -8
- package/src/series.test.ts +7 -23
- package/src/series.ts +0 -19
- package/src/tactical-labels.ts +80 -0
- package/src/team.test.ts +19 -67
- package/src/team.ts +307 -127
- package/src/test-fixtures.ts +191 -0
- package/src/tournament-compat.ts +591 -0
- package/src/trails.test.ts +63 -0
- package/src/trails.ts +119 -0
- package/src/weapons.ts +1 -1
- package/src/workspace-utils.ts +2 -12
- package/src/workspace.ts +395 -83
- package/src/labels.ts +0 -3
package/LICENSE
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
Licensing note / 适用范围
|
|
2
|
+
-------------------------
|
|
3
|
+
The MIT License below applies to this repository EXCEPT apps/dak-studio/,
|
|
4
|
+
which is licensed under the GNU AGPL-3.0-only (see apps/dak-studio/LICENSE).
|
|
5
|
+
Ecosystem packages (@cs2dak/*, python exporter) are MIT; the DAK Studio
|
|
6
|
+
product is AGPL. Third-party attributions: THIRD-PARTY-NOTICES.md.
|
|
7
|
+
|
|
1
8
|
MIT License
|
|
2
9
|
|
|
3
10
|
Copyright (c) 2026 Starfie1d
|
package/package.json
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cs2dak/presentation",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@rivalhub/rival-rating": "^0.
|
|
11
|
-
"@cs2dak/contract": "1.
|
|
12
|
-
"@cs2dak/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"@cs2dak/
|
|
10
|
+
"@rivalhub/rival-rating": "^0.3.0",
|
|
11
|
+
"@cs2dak/contract": "1.1.0",
|
|
12
|
+
"@cs2dak/cohort": "2.0.0",
|
|
13
|
+
"@cs2dak/maps": "1.0.0",
|
|
14
|
+
"@cs2dak/core": "2.0.0",
|
|
15
|
+
"@cs2dak/tournament": "1.0.0"
|
|
16
16
|
},
|
|
17
|
+
"devDependencies": {},
|
|
17
18
|
"files": [
|
|
18
19
|
"src"
|
|
19
20
|
],
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TeamAwpRoundFact } from "@cs2dak/contract";
|
|
3
|
+
import { buildDoubleAwpAnalyses } from "./double-awp.js";
|
|
4
|
+
|
|
5
|
+
function row(roundNumber: number, overrides: Partial<TeamAwpRoundFact> = {}): TeamAwpRoundFact {
|
|
6
|
+
return {
|
|
7
|
+
analysisVersion: 6, matchId: `m${Math.ceil(roundNumber / 3)}`, mapName: roundNumber % 2 ? "de_nuke" : "de_inferno", roundNumber,
|
|
8
|
+
teamKey: "teamA", side: "ct", economyType: "full", opponentEconomyType: "full", scorePhase: "first_half", won: roundNumber % 2 === 0,
|
|
9
|
+
roundStartAwpPlayerIndices: [0, 1], doubleAwpActiveSeconds: 12, awpActiveSeconds: 28, awpShots: 4, awpKills: 2, awpDamage: null,
|
|
10
|
+
openingKills: 1, openingDeaths: 0,
|
|
11
|
+
availability: { replay: "available", nav: "available", callouts: "available", shots: "available" }, ...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("double AWP analysis", () => {
|
|
16
|
+
it("keeps CT qualified conditions, combinations and observable contribution fields", () => {
|
|
17
|
+
const model = buildDoubleAwpAnalyses(Array.from({ length: 6 }, (_, index) => row(index + 1)), { teamKeyFor: () => "Team", playerKeyFor: (_match, index) => `p${index}` })[0]!;
|
|
18
|
+
expect(model).toMatchObject({ teamKey: "Team", side: "ct", status: "ready", doubleAwpRoundCount: 6, qualifiedRoundCount: 6, openingKills: 6, awpKills: 12, awpDamage: null });
|
|
19
|
+
expect(model.combinations[0]).toEqual({ playerKeys: ["p0", "p1"], rounds: 6 });
|
|
20
|
+
expect(model.mapDistribution).toHaveLength(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not merge T-side double AWP into CT output and excludes force rounds from the denominator", () => {
|
|
24
|
+
const models = buildDoubleAwpAnalyses([row(1), row(2, { side: "t" }), row(3, { economyType: "force" })], { teamKeyFor: () => "Team" });
|
|
25
|
+
expect(models).toHaveLength(2);
|
|
26
|
+
expect(models.find((model) => model.side === "ct")?.qualifiedRoundCount).toBe(1);
|
|
27
|
+
expect(models.find((model) => model.side === "t")?.doubleAwpRoundCount).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { doubleAwpAnalysisSchema, type DoubleAwpAnalysis, type TeamAwpRoundFact } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
export interface DoubleAwpAnalysisOptions {
|
|
4
|
+
teamKeyFor?: (matchId: string, rawTeamKey: "teamA" | "teamB") => string;
|
|
5
|
+
playerKeyFor?: (matchId: string, playerIndex: number) => string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function breakdown(values: string[]) {
|
|
9
|
+
const counts = new Map<string, number>();
|
|
10
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
11
|
+
return [...counts.entries()].map(([key, rounds]) => ({ key, rounds })).sort((a, b) => b.rounds - a.rounds || a.key.localeCompare(b.key));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildDoubleAwpAnalyses(rows: TeamAwpRoundFact[], options: DoubleAwpAnalysisOptions = {}): DoubleAwpAnalysis[] {
|
|
15
|
+
const groups = new Map<string, TeamAwpRoundFact[]>();
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const teamKey = options.teamKeyFor?.(row.matchId, row.teamKey) ?? `${row.matchId}:${row.teamKey}`;
|
|
18
|
+
const key = `${teamKey}\t${row.side}`;
|
|
19
|
+
groups.set(key, [...(groups.get(key) ?? []), row]);
|
|
20
|
+
}
|
|
21
|
+
return [...groups.entries()].map(([key, group]) => {
|
|
22
|
+
const [teamKey, side] = key.split("\t") as [string, "t" | "ct"];
|
|
23
|
+
const qualified = group.filter((row) => row.economyType === "full");
|
|
24
|
+
const double = qualified.filter((row) => row.roundStartAwpPlayerIndices.length >= 2);
|
|
25
|
+
const comboCounts = new Map<string, { playerKeys: string[]; rounds: number }>();
|
|
26
|
+
for (const row of double) {
|
|
27
|
+
const playerKeys = row.roundStartAwpPlayerIndices.map((index) => options.playerKeyFor?.(row.matchId, index) ?? `${row.matchId}:player:${index}`).sort();
|
|
28
|
+
const comboKey = playerKeys.join("|");
|
|
29
|
+
const existing = comboCounts.get(comboKey) ?? { playerKeys, rounds: 0 };
|
|
30
|
+
existing.rounds += 1;
|
|
31
|
+
comboCounts.set(comboKey, existing);
|
|
32
|
+
}
|
|
33
|
+
const sumNullable = (pick: (row: TeamAwpRoundFact) => number | null): number | null => double.some((row) => pick(row) != null) ? double.reduce((sum, row) => sum + (pick(row) ?? 0), 0) : null;
|
|
34
|
+
const status = group.every((row) => row.availability.replay === "missing") ? "unknown" : qualified.length < 6 ? "insufficient" : "ready";
|
|
35
|
+
return doubleAwpAnalysisSchema.parse({
|
|
36
|
+
version: "cs2-demo-analysis-kit/double-awp-analysis-2.0", teamKey, side, status,
|
|
37
|
+
qualifiedRoundCount: qualified.length, doubleAwpRoundCount: double.length, eligibleRoundShare: qualified.length ? double.length / qualified.length : null,
|
|
38
|
+
combinations: [...comboCounts.values()].sort((a, b) => b.rounds - a.rounds || a.playerKeys.join("|").localeCompare(b.playerKeys.join("|"))),
|
|
39
|
+
mapDistribution: breakdown(double.map((row) => row.mapName)), scorePhaseDistribution: breakdown(double.map((row) => row.scorePhase)),
|
|
40
|
+
economyDistribution: breakdown(double.map((row) => row.economyType)), opponentEconomyDistribution: breakdown(double.map((row) => row.opponentEconomyType)),
|
|
41
|
+
wins: double.filter((row) => row.won).length, winRate: double.length ? double.filter((row) => row.won).length / double.length : null,
|
|
42
|
+
openingKills: double.reduce((sum, row) => sum + row.openingKills, 0), openingDeaths: double.reduce((sum, row) => sum + row.openingDeaths, 0),
|
|
43
|
+
roundStartAwpOwnerships: double.reduce((sum, row) => sum + row.roundStartAwpPlayerIndices.length, 0),
|
|
44
|
+
activeAwpSeconds: sumNullable((row) => row.awpActiveSeconds), doubleAwpActiveSeconds: sumNullable((row) => row.doubleAwpActiveSeconds), awpKills: sumNullable((row) => row.awpKills), awpDamage: sumNullable((row) => row.awpDamage),
|
|
45
|
+
evidence: double.slice(0, 8).map((row) => ({ matchId: row.matchId, roundNumber: row.roundNumber, reason: `${row.mapName} ${row.side.toUpperCase()} 双 AWP 长枪局`, role: "example" as const })),
|
|
46
|
+
basis: ["仅按逐回合 round-start AWP ownership、存活 active time 和有效生命伤害描述双 AWP 使用;CT/T 分开。"],
|
|
47
|
+
limitations: ["胜率和开局数据是条件统计,不表示双 AWP 导致结果。", ...(double.some((row) => row.awpDamage == null) ? ["当前 ZIP 缺少可用 damages 数据,AWP damage 保持 null。"] : [])],
|
|
48
|
+
});
|
|
49
|
+
}).sort((a, b) => a.teamKey.localeCompare(b.teamKey) || a.side.localeCompare(b.side));
|
|
50
|
+
}
|
package/src/duel.test.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { DemoPackage, PackageDamage, PackageKill, PackageShots } from "@cs2dak/contract";
|
|
3
|
+
import { duelInsightsModelSchema } from "@cs2dak/contract";
|
|
4
|
+
import { buildDuelInsights, buildDuelInsightsFromFacts, extractDuelInsightsFacts } from "./duel.js";
|
|
5
|
+
|
|
6
|
+
const A = "76561198000000001";
|
|
7
|
+
const B = "76561198000000002";
|
|
8
|
+
|
|
9
|
+
// Player indices matching pkg().players order
|
|
10
|
+
const AI = 0;
|
|
11
|
+
const BI = 1;
|
|
12
|
+
|
|
13
|
+
/** Build columnar PackageShots from a flat shot list. Ticks are delta-encoded per track. */
|
|
14
|
+
function buildShots(
|
|
15
|
+
shots: Array<{ tick: number; playerIndex: number; roundNumber?: number; weaponIndex?: number }>
|
|
16
|
+
): PackageShots {
|
|
17
|
+
const groups = new Map<string, Array<{ tick: number; playerIndex: number; roundNumber: number; weaponIndex: number }>>();
|
|
18
|
+
for (const s of shots) {
|
|
19
|
+
const rn = s.roundNumber ?? 1;
|
|
20
|
+
const key = `${rn}:${s.playerIndex}`;
|
|
21
|
+
const arr = groups.get(key) ?? [];
|
|
22
|
+
arr.push({ tick: s.tick, playerIndex: s.playerIndex, roundNumber: rn, weaponIndex: s.weaponIndex ?? 0 });
|
|
23
|
+
groups.set(key, arr);
|
|
24
|
+
}
|
|
25
|
+
const tracks: PackageShots["tracks"] = [];
|
|
26
|
+
for (const items of groups.values()) {
|
|
27
|
+
const deltas: number[] = [];
|
|
28
|
+
let prev = 0;
|
|
29
|
+
for (const { tick } of items) { deltas.push(tick - prev); prev = tick; }
|
|
30
|
+
const n = items.length;
|
|
31
|
+
tracks.push({
|
|
32
|
+
roundNumber: items[0]!.roundNumber,
|
|
33
|
+
playerIndex: items[0]!.playerIndex,
|
|
34
|
+
tick: deltas,
|
|
35
|
+
weapon: items.map((s) => s.weaponIndex),
|
|
36
|
+
vx: Array<number>(n).fill(0),
|
|
37
|
+
vy: Array<number>(n).fill(0),
|
|
38
|
+
vz: Array<number>(n).fill(0),
|
|
39
|
+
yaw: Array<number>(n).fill(0),
|
|
40
|
+
pitch: Array<number>(n).fill(0),
|
|
41
|
+
x: Array<number>(n).fill(0),
|
|
42
|
+
y: Array<number>(n).fill(0),
|
|
43
|
+
z: Array<number>(n).fill(0),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { meta: { coordScale: 1, angleScale: 10 }, weaponDict: ["ak47", "glock"], tracks };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function damage(row: { attackerIndex: number | null; victimIndex: number; tick: number } & Partial<PackageDamage>): PackageDamage {
|
|
50
|
+
return {
|
|
51
|
+
roundNumber: 1,
|
|
52
|
+
weapon: "ak47",
|
|
53
|
+
hitgroup: "head",
|
|
54
|
+
healthDamage: 100,
|
|
55
|
+
healthDamageRaw: 100,
|
|
56
|
+
armorDamage: 0,
|
|
57
|
+
victimHealthBefore: 100,
|
|
58
|
+
attackerPosition: { x: 0, y: 0, z: 0 },
|
|
59
|
+
victimPosition: { x: 100, y: 0, z: 0 },
|
|
60
|
+
...row
|
|
61
|
+
} as PackageDamage;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function kill(row: { killerIndex: number | null; victimIndex: number; tick: number } & Partial<PackageKill>): PackageKill {
|
|
65
|
+
return {
|
|
66
|
+
roundNumber: 1,
|
|
67
|
+
assisterIndex: null,
|
|
68
|
+
flashAssisterIndex: null,
|
|
69
|
+
weapon: "ak47",
|
|
70
|
+
killerActiveWeapon: "ak47",
|
|
71
|
+
victimActiveWeapon: "ak47",
|
|
72
|
+
headshot: true,
|
|
73
|
+
flashAssist: false,
|
|
74
|
+
tradeKill: false,
|
|
75
|
+
tradeDeath: false,
|
|
76
|
+
throughSmoke: false,
|
|
77
|
+
noScope: false,
|
|
78
|
+
penetratedObjects: 0,
|
|
79
|
+
killerPosition: { x: 0, y: 0, z: 0 },
|
|
80
|
+
victimPosition: { x: 100, y: 0, z: 0 },
|
|
81
|
+
...row
|
|
82
|
+
} as PackageKill;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pkg(shots?: PackageShots): DemoPackage {
|
|
86
|
+
return {
|
|
87
|
+
manifest: {
|
|
88
|
+
schemaVersion: "cs2-demo-format/3.0",
|
|
89
|
+
exporter: { name: "test", version: "0" },
|
|
90
|
+
parser: { name: "test", version: "0" },
|
|
91
|
+
demo: { hash: null, sourceFileName: null },
|
|
92
|
+
mapName: "de_mirage",
|
|
93
|
+
tickrate: 64,
|
|
94
|
+
exportedAt: "2026-01-01T00:00:00Z",
|
|
95
|
+
files: {
|
|
96
|
+
match: "match.json",
|
|
97
|
+
players: "players.json",
|
|
98
|
+
rounds: "rounds.json",
|
|
99
|
+
playerStats: "player-stats.json",
|
|
100
|
+
playerEconomies: "player-economies.json",
|
|
101
|
+
kills: "kills.json",
|
|
102
|
+
damages: "damages.json",
|
|
103
|
+
blinds: "blinds.json",
|
|
104
|
+
bombs: "bombs.json",
|
|
105
|
+
grenades: "grenades.json",
|
|
106
|
+
clutches: "clutches.json"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
match: {
|
|
110
|
+
mapName: "de_mirage",
|
|
111
|
+
tickrate: 64,
|
|
112
|
+
durationSeconds: 120,
|
|
113
|
+
serverName: null,
|
|
114
|
+
source: "test",
|
|
115
|
+
teamA: { teamKey: "teamA", name: "A Team", score: 1 },
|
|
116
|
+
teamB: { teamKey: "teamB", name: "B Team", score: 0 }
|
|
117
|
+
},
|
|
118
|
+
players: [
|
|
119
|
+
{ steamId64: A, name: "Alpha", teamKey: "teamA" },
|
|
120
|
+
{ steamId64: B, name: "Bravo", teamKey: "teamB" }
|
|
121
|
+
],
|
|
122
|
+
rounds: [],
|
|
123
|
+
playerEconomies: [],
|
|
124
|
+
playerStats: [],
|
|
125
|
+
kills: [kill({ tick: 120, killerIndex: AI, victimIndex: BI })],
|
|
126
|
+
damages: [damage({ tick: 120, attackerIndex: AI, victimIndex: BI })],
|
|
127
|
+
blinds: [],
|
|
128
|
+
bombs: [],
|
|
129
|
+
grenades: [],
|
|
130
|
+
clutches: [],
|
|
131
|
+
shots
|
|
132
|
+
} as DemoPackage;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("buildDuelInsights", () => {
|
|
136
|
+
it("builds the same model from persisted duel facts", () => {
|
|
137
|
+
const inputs = [
|
|
138
|
+
{ matchId: "m1", pkg: pkg(buildShots([{ tick: 100, playerIndex: AI }, { tick: 120, playerIndex: AI }])) },
|
|
139
|
+
{ matchId: "m2", pkg: pkg(buildShots([{ tick: 100, playerIndex: AI }, { tick: 120, playerIndex: AI }])) }
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
expect(buildDuelInsightsFromFacts(inputs.map((input) => extractDuelInsightsFacts(input)))).toEqual(
|
|
143
|
+
buildDuelInsights(inputs)
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("builds a schema-valid model with raw values and current-range percentile labels", () => {
|
|
148
|
+
const model = buildDuelInsights([{ matchId: "m1", pkg: pkg(buildShots([{ tick: 100, playerIndex: AI }, { tick: 120, playerIndex: AI }])) }]);
|
|
149
|
+
expect(() => duelInsightsModelSchema.parse(model)).not.toThrow();
|
|
150
|
+
expect(model.duelRows[0].killerName).toBe("Alpha");
|
|
151
|
+
expect(model.mechanicsRows[0].metrics.some((metric) => metric.percentileLabel?.includes("当前范围"))).toBe(true);
|
|
152
|
+
expect(JSON.stringify(model)).not.toContain("letterGrade");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("hides unavailable mechanics metrics when shots are missing", () => {
|
|
156
|
+
const model = buildDuelInsights([{ matchId: "m1", pkg: pkg(undefined) }]);
|
|
157
|
+
expect(model.duelRows).toHaveLength(1);
|
|
158
|
+
expect(model.mechanicsRows).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("aggregates the same player+weapon across matches into a single tailored row", () => {
|
|
162
|
+
const withShots = () => pkg(buildShots([{ tick: 100, playerIndex: AI }, { tick: 120, playerIndex: AI }]));
|
|
163
|
+
const model = buildDuelInsights([
|
|
164
|
+
{ matchId: "m1", pkg: withShots() },
|
|
165
|
+
{ matchId: "m2", pkg: withShots() }
|
|
166
|
+
]);
|
|
167
|
+
const akRows = model.mechanicsRows.filter((row) => row.steamId64 === A && row.weapon === "ak47");
|
|
168
|
+
expect(akRows).toHaveLength(1); // 两场的 AK 合并为一行,而非各出一行
|
|
169
|
+
expect(akRows[0]!.killCount).toBe(2);
|
|
170
|
+
// 场均击杀 = 2 杀 / 2 场
|
|
171
|
+
expect(akRows[0]!.metrics.find((metric) => metric.key === "killsPerMatch")?.value).toBe(2 / 2);
|
|
172
|
+
// 爆头率:两场都是爆头击杀
|
|
173
|
+
expect(akRows[0]!.metrics.find((metric) => metric.key === "headshot")?.value).toBe(100);
|
|
174
|
+
// 步枪类别展示扫射命中率,而狙击会隐藏击杀耗时/预瞄(此处验证步枪保留 sprayHit)
|
|
175
|
+
const keys = akRows[0]!.metrics.map((metric) => metric.key);
|
|
176
|
+
expect(keys).toContain("sprayHit");
|
|
177
|
+
expect(keys).toContain("headshot");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not show one tap for weapons that cannot one-shot full-health targets", () => {
|
|
181
|
+
const demo = pkg(buildShots([{ tick: 100, playerIndex: AI, weaponIndex: 1 }, { tick: 120, playerIndex: AI, weaponIndex: 1 }]));
|
|
182
|
+
demo.kills = [kill({ tick: 120, killerIndex: AI, victimIndex: BI, weapon: "glock", killerActiveWeapon: "glock" })];
|
|
183
|
+
demo.damages = [damage({ tick: 120, attackerIndex: AI, victimIndex: BI, weapon: "glock" })];
|
|
184
|
+
|
|
185
|
+
const model = buildDuelInsights([{ matchId: "m1", pkg: demo }]);
|
|
186
|
+
const glock = model.mechanicsRows.find((row) => row.weapon === "glock")!;
|
|
187
|
+
|
|
188
|
+
expect(glock.metrics.map((metric) => metric.key)).not.toContain("oneTap");
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/duel.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { DemoPackage, DuelInsightsModel, DuelFinderRow, MechanicsMetric, OpeningDuelRow, PlayerMechanicsRow } from "@cs2dak/contract";
|
|
2
|
+
import { duelInsightsModelSchema } from "@cs2dak/contract";
|
|
3
|
+
import { deriveDuels, deriveOpeningDuels, derivePlayerMechanics, type PlayerMechanicsFact, type RateSample } from "@cs2dak/core";
|
|
4
|
+
import type { TriangleBvh } from "@cs2dak/maps";
|
|
5
|
+
|
|
6
|
+
export interface DuelInsightsInput {
|
|
7
|
+
matchId: string;
|
|
8
|
+
pkg: DemoPackage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DuelInsightsFacts {
|
|
12
|
+
matchId: string;
|
|
13
|
+
duelRows: DuelFinderRow[];
|
|
14
|
+
openingRows: OpeningDuelRow[];
|
|
15
|
+
mechanicsRows: PlayerMechanicsFact[];
|
|
16
|
+
teamNamesBySteamId: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DuelInsightsOptions {
|
|
20
|
+
/** 按地图名提供 .tri BVH;提供后分类、反应时间、预瞄与首发可见性走静态 LOS 精确口径。 */
|
|
21
|
+
visibilityFor?: (mapName: string) => TriangleBvh | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CLASSIFICATION_LABEL: Record<string, string> = {
|
|
25
|
+
contested_duel: "正面对枪击杀",
|
|
26
|
+
suppressed_kill: "先手压制击杀",
|
|
27
|
+
caught_off_guard: "无有效视野击杀"
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function nameFor(pkg: DemoPackage, steamId64: string): string {
|
|
31
|
+
return pkg.players.find((player) => player.steamId64 === steamId64)?.name ?? steamId64;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CS2 标准回合时长(秒,freeze 结束后),用于回合剩余时间倒计时显示。 */
|
|
35
|
+
const ROUND_DURATION = 115;
|
|
36
|
+
|
|
37
|
+
/** 对枪发生时的回合剩余时间标签("1:23"),无 freezeEnd 时为 null。 */
|
|
38
|
+
function roundTimeLabelFor(input: DuelInsightsInput, fact: ReturnType<typeof deriveDuels>[number]): string | null {
|
|
39
|
+
const tickrate = input.pkg.match.tickrate || input.pkg.manifest.tickrate || 64;
|
|
40
|
+
const roundRow = input.pkg.rounds.find((r) => r.roundNumber === fact.roundNumber);
|
|
41
|
+
if (!roundRow || roundRow.freezeEndTick == null || tickrate <= 0) return null;
|
|
42
|
+
const elapsed = (fact.tick - roundRow.freezeEndTick) / tickrate;
|
|
43
|
+
const remaining = Math.max(0, ROUND_DURATION - elapsed);
|
|
44
|
+
const min = Math.floor(remaining / 60);
|
|
45
|
+
const sec = Math.round(remaining % 60);
|
|
46
|
+
return `${min}:${String(sec).padStart(2, "0")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function duelRow(input: DuelInsightsInput, fact: ReturnType<typeof deriveDuels>[number]): DuelFinderRow {
|
|
50
|
+
return {
|
|
51
|
+
id: `${input.matchId}:${fact.id}`,
|
|
52
|
+
matchId: input.matchId,
|
|
53
|
+
mapName: input.pkg.match.mapName,
|
|
54
|
+
roundNumber: fact.roundNumber,
|
|
55
|
+
tick: fact.tick,
|
|
56
|
+
killerSteamId64: fact.killerSteamId64,
|
|
57
|
+
victimSteamId64: fact.victimSteamId64,
|
|
58
|
+
killerName: nameFor(input.pkg, fact.killerSteamId64),
|
|
59
|
+
victimName: nameFor(input.pkg, fact.victimSteamId64),
|
|
60
|
+
weapon: fact.weapon,
|
|
61
|
+
classification: fact.classification,
|
|
62
|
+
hpBucket: fact.hpBucket,
|
|
63
|
+
thirdParty: fact.thirdParty,
|
|
64
|
+
fullHealth: fact.fullHealth,
|
|
65
|
+
victimHealthBefore: fact.victimHealthBefore,
|
|
66
|
+
killerHealthBefore: fact.killerHealthBefore,
|
|
67
|
+
ttkMs: fact.ttkMs,
|
|
68
|
+
oneShotKill: fact.oneShotKill,
|
|
69
|
+
evidenceTicks: fact.evidenceTicks,
|
|
70
|
+
killerPosition: fact.killerPosition,
|
|
71
|
+
victimPosition: fact.victimPosition,
|
|
72
|
+
roundTimeLabel: roundTimeLabelFor(input, fact),
|
|
73
|
+
evidence: {
|
|
74
|
+
matchId: input.matchId,
|
|
75
|
+
roundNumber: fact.roundNumber,
|
|
76
|
+
tick: fact.tick,
|
|
77
|
+
reason: CLASSIFICATION_LABEL[fact.classification] ?? "对枪事件",
|
|
78
|
+
role: "example"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function openingRow(input: DuelInsightsInput, fact: ReturnType<typeof deriveDuels>[number]): OpeningDuelRow {
|
|
84
|
+
return {
|
|
85
|
+
...duelRow(input, fact),
|
|
86
|
+
attackerCallout: null,
|
|
87
|
+
victimCallout: null
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function round1(value: number): number {
|
|
92
|
+
return Math.round(value * 10) / 10;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function median(values: number[]): number | null {
|
|
96
|
+
if (values.length === 0) return null;
|
|
97
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
98
|
+
const mid = Math.floor(sorted.length / 2);
|
|
99
|
+
return sorted.length % 2 === 0 ? round1((sorted[mid - 1]! + sorted[mid]!) / 2) : round1(sorted[mid]!);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rate(successes: number, attempts: number): RateSample {
|
|
103
|
+
return { value: attempts > 0 ? round1((successes / attempts) * 100) : null, successes, attempts };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sumRate(a: RateSample, b: RateSample): RateSample {
|
|
107
|
+
return rate(a.successes + b.successes, a.attempts + b.attempts);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rankLabel(value: number | null, values: number[], higherIsBetter = true): string | null {
|
|
111
|
+
if (value == null || values.length < 1) return null;
|
|
112
|
+
const sorted = [...values].sort((a, b) => higherIsBetter ? b - a : a - b);
|
|
113
|
+
const rank = sorted.findIndex((candidate) => candidate === value) + 1;
|
|
114
|
+
return rank > 0 ? `当前范围前 ${Math.max(1, Math.round((rank / values.length) * 100))}%` : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 武器分类 → 定制展示指标集(参考 5E「武器分析」:每类武器只展示有意义的特征)──
|
|
118
|
+
export type MechanicsMetricKey = "firstShotHit" | "sprayHit" | "counterStrafe" | "oneTap" | "ttk" | "reaction" | "preaim" | "headshot" | "killsPerMatch";
|
|
119
|
+
type MetricKey = MechanicsMetricKey;
|
|
120
|
+
export type WeaponCategory = "rifle" | "sniper" | "pistol" | "submachine_gun" | "shotgun_lmg" | "other";
|
|
121
|
+
|
|
122
|
+
const RIFLES = new Set(["ak47", "m4a1", "m4a4", "m4a1_silencer", "aug", "sg556", "sg553", "famas", "galilar", "galil"]);
|
|
123
|
+
const SNIPERS = new Set(["awp", "ssg08", "scar20", "g3sg1"]);
|
|
124
|
+
const PISTOLS = new Set(["deagle", "revolver", "glock", "usp_silencer", "usp", "hkp2000", "p2000", "p250", "fiveseven", "tec9", "cz75a", "cz75", "elite"]);
|
|
125
|
+
const SUBMACHINE_GUNS = new Set(["mp9", "mp7", "mp5sd", "ump45", "p90", "bizon", "mac10"]);
|
|
126
|
+
const SHOTGUNS_LMG = new Set(["nova", "xm1014", "mag7", "sawedoff", "m249", "negev"]);
|
|
127
|
+
const ONE_TAP_CAPABLE = new Set(["ak47", "sg556", "sg553", "deagle", "revolver", "awp", "ssg08", "scar20", "g3sg1"]);
|
|
128
|
+
|
|
129
|
+
export function weaponCategory(weapon: string): WeaponCategory {
|
|
130
|
+
const w = weapon.toLowerCase();
|
|
131
|
+
if (RIFLES.has(w)) return "rifle";
|
|
132
|
+
if (SNIPERS.has(w)) return "sniper";
|
|
133
|
+
if (PISTOLS.has(w)) return "pistol";
|
|
134
|
+
if (SUBMACHINE_GUNS.has(w)) return "submachine_gun";
|
|
135
|
+
if (SHOTGUNS_LMG.has(w)) return "shotgun_lmg";
|
|
136
|
+
return "other";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 狙击:去击杀耗时(≈0)/扫射/one tap(恒满)/预瞄(多为架枪);手枪:去扫射;冲锋枪:弱化预瞄。
|
|
140
|
+
export const CATEGORY_METRICS: Record<WeaponCategory, MetricKey[]> = {
|
|
141
|
+
rifle: ["firstShotHit", "sprayHit", "counterStrafe", "ttk", "oneTap", "reaction", "preaim", "headshot", "killsPerMatch"],
|
|
142
|
+
sniper: ["firstShotHit", "counterStrafe", "reaction", "headshot", "killsPerMatch"],
|
|
143
|
+
pistol: ["firstShotHit", "counterStrafe", "ttk", "oneTap", "reaction", "headshot", "killsPerMatch"],
|
|
144
|
+
submachine_gun: ["firstShotHit", "sprayHit", "counterStrafe", "ttk", "reaction", "headshot", "killsPerMatch"],
|
|
145
|
+
shotgun_lmg: ["firstShotHit", "counterStrafe", "ttk", "reaction", "headshot", "killsPerMatch"],
|
|
146
|
+
other: ["firstShotHit", "counterStrafe", "reaction", "headshot", "killsPerMatch"]
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function mechanicsMetricsForWeapon(weapon: string): MetricKey[] {
|
|
150
|
+
const category = weaponCategory(weapon);
|
|
151
|
+
const keys = CATEGORY_METRICS[category];
|
|
152
|
+
if (ONE_TAP_CAPABLE.has(weapon.toLowerCase())) return keys;
|
|
153
|
+
return keys.filter((key) => key !== "oneTap");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const LOWER_IS_BETTER = new Set<MetricKey>(["ttk", "reaction", "preaim"]);
|
|
157
|
+
|
|
158
|
+
/** 跨场按 (steamId64, weapon) 聚合后的枪法事实。 */
|
|
159
|
+
interface AggMechanics {
|
|
160
|
+
steamId64: string;
|
|
161
|
+
playerName: string;
|
|
162
|
+
teamName: string;
|
|
163
|
+
weapon: string;
|
|
164
|
+
killCount: number;
|
|
165
|
+
cleanKillCount: number;
|
|
166
|
+
headshotKills: number;
|
|
167
|
+
cleanHeadshotKills: number;
|
|
168
|
+
shotCount: number;
|
|
169
|
+
burstCount: number;
|
|
170
|
+
firstShotHit: RateSample;
|
|
171
|
+
sprayHit: RateSample | null;
|
|
172
|
+
counterStrafe: RateSample;
|
|
173
|
+
oneTap: RateSample;
|
|
174
|
+
ttkSamples: number[];
|
|
175
|
+
reactionSamples: number[];
|
|
176
|
+
preaimSamples: number[];
|
|
177
|
+
burstLengthBuckets: PlayerMechanicsFact["burstLengthBuckets"];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 取某指标的标量值(用于该 agg 展示 + 跨 agg 百分位)。 */
|
|
181
|
+
function metricValueOf(agg: AggMechanics, key: MetricKey, matchesPlayed: number): number | null {
|
|
182
|
+
switch (key) {
|
|
183
|
+
case "firstShotHit": return agg.firstShotHit.value;
|
|
184
|
+
case "sprayHit": return agg.sprayHit?.value ?? null;
|
|
185
|
+
case "counterStrafe": return agg.counterStrafe.value;
|
|
186
|
+
case "oneTap": return agg.oneTap.value;
|
|
187
|
+
case "ttk": return median(agg.ttkSamples);
|
|
188
|
+
case "reaction": return median(agg.reactionSamples);
|
|
189
|
+
case "preaim": return median(agg.preaimSamples);
|
|
190
|
+
case "headshot": return agg.cleanKillCount > 0 ? round1((agg.cleanHeadshotKills / agg.cleanKillCount) * 100) : null;
|
|
191
|
+
case "killsPerMatch": return matchesPlayed > 0 ? round1(agg.killCount / matchesPlayed) : null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildMetric(key: MetricKey, agg: AggMechanics, allAggs: AggMechanics[], matchesByPlayer: Map<string, number>): MechanicsMetric {
|
|
196
|
+
const matchesPlayed = matchesByPlayer.get(agg.steamId64) ?? 1;
|
|
197
|
+
const value = metricValueOf(agg, key, matchesPlayed);
|
|
198
|
+
const peers = allAggs
|
|
199
|
+
.map((other) => metricValueOf(other, key, matchesByPlayer.get(other.steamId64) ?? 1))
|
|
200
|
+
.filter((candidate): candidate is number => candidate != null);
|
|
201
|
+
const percentileLabel = rankLabel(value, peers, !LOWER_IS_BETTER.has(key));
|
|
202
|
+
const base = { key, value, percentileLabel };
|
|
203
|
+
switch (key) {
|
|
204
|
+
case "firstShotHit": return { ...base, label: "首发命中率", unit: "%", successes: agg.firstShotHit.successes, attempts: agg.firstShotHit.attempts };
|
|
205
|
+
case "sprayHit": return { ...base, label: "扫射命中率", unit: "%", successes: agg.sprayHit?.successes, attempts: agg.sprayHit?.attempts };
|
|
206
|
+
case "counterStrafe": return { ...base, label: "急停成功率", unit: "%", successes: agg.counterStrafe.successes, attempts: agg.counterStrafe.attempts };
|
|
207
|
+
case "oneTap": return { ...base, label: "one tap 率", unit: "%", successes: agg.oneTap.successes, attempts: agg.oneTap.attempts };
|
|
208
|
+
case "ttk": return { ...base, label: "击杀耗时", unit: "ms", sampleSize: agg.ttkSamples.length };
|
|
209
|
+
case "reaction": return { ...base, label: "反应时间", unit: "ms", sampleSize: agg.reactionSamples.length };
|
|
210
|
+
case "preaim": {
|
|
211
|
+
const withinFive = agg.preaimSamples.filter((value) => value <= 5).length;
|
|
212
|
+
return {
|
|
213
|
+
...base,
|
|
214
|
+
label: "预瞄误差",
|
|
215
|
+
unit: "°",
|
|
216
|
+
sampleSize: agg.preaimSamples.length,
|
|
217
|
+
detail: agg.preaimSamples.length > 0 ? `${withinFive}/${agg.preaimSamples.length} ≤5°` : undefined
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
case "headshot": return { ...base, label: "爆头率", unit: "%", successes: agg.cleanHeadshotKills, attempts: agg.cleanKillCount };
|
|
221
|
+
case "killsPerMatch": return { ...base, label: "场均击杀", unit: "", detail: `${agg.killCount} 杀 / ${matchesPlayed} 场` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 单条武器画像:按武器类别只展示有意义的指标。命中率类带分子/分母,中位类带样本数;
|
|
227
|
+
* 击杀耗时 / 反应时间 / 预瞄误差 越低越好。
|
|
228
|
+
*/
|
|
229
|
+
function mechanicsRow(agg: AggMechanics, allAggs: AggMechanics[], matchesByPlayer: Map<string, number>): PlayerMechanicsRow {
|
|
230
|
+
const metrics: MechanicsMetric[] = mechanicsMetricsForWeapon(agg.weapon).map((key) => buildMetric(key, agg, allAggs, matchesByPlayer));
|
|
231
|
+
const buckets = agg.burstLengthBuckets;
|
|
232
|
+
const total = buckets.single + buckets.short + buckets.medium + buckets.long;
|
|
233
|
+
return {
|
|
234
|
+
steamId64: agg.steamId64,
|
|
235
|
+
playerName: agg.playerName,
|
|
236
|
+
teamName: agg.teamName,
|
|
237
|
+
weapon: agg.weapon,
|
|
238
|
+
killCount: agg.killCount,
|
|
239
|
+
shotCount: agg.shotCount,
|
|
240
|
+
burstCount: agg.burstCount,
|
|
241
|
+
metrics,
|
|
242
|
+
burstLengthBuckets: buckets,
|
|
243
|
+
firingPatternRatio: total > 0
|
|
244
|
+
? { tap: round1((buckets.single / total) * 100), burst: round1(((buckets.short + buckets.medium) / total) * 100), spray: round1((buckets.long / total) * 100) }
|
|
245
|
+
: { tap: 0, burst: 0, spray: 0 }
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function duelClassificationLabel(classification: string): string {
|
|
250
|
+
return CLASSIFICATION_LABEL[classification] ?? classification;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function extractDuelInsightsFacts(input: DuelInsightsInput, options: DuelInsightsOptions = {}): DuelInsightsFacts {
|
|
254
|
+
const visibility = options.visibilityFor?.(input.pkg.match.mapName) ?? null;
|
|
255
|
+
const teamNames = {
|
|
256
|
+
teamA: input.pkg.match.teamA.name ?? "Team A",
|
|
257
|
+
teamB: input.pkg.match.teamB.name ?? "Team B"
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
matchId: input.matchId,
|
|
261
|
+
duelRows: deriveDuels(input.pkg, { visibility }).map((fact) => duelRow(input, fact)),
|
|
262
|
+
openingRows: deriveOpeningDuels(input.pkg, { visibility }).map((fact) => openingRow(input, fact)),
|
|
263
|
+
mechanicsRows: derivePlayerMechanics(input.pkg, { visibility }),
|
|
264
|
+
teamNamesBySteamId: Object.fromEntries(
|
|
265
|
+
input.pkg.players.map((player) => [player.steamId64, teamNames[player.teamKey]])
|
|
266
|
+
)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function buildDuelInsightsFromFacts(facts: DuelInsightsFacts[]): DuelInsightsModel {
|
|
271
|
+
const duelRows: DuelFinderRow[] = [];
|
|
272
|
+
const openingRows: OpeningDuelRow[] = [];
|
|
273
|
+
// 跨场按 (steamId64, weapon) 聚合,避免同一选手同把武器在多场各出一行。
|
|
274
|
+
const aggByKey = new Map<string, AggMechanics>();
|
|
275
|
+
const matchesByPlayer = new Map<string, Set<string>>();
|
|
276
|
+
|
|
277
|
+
for (const input of facts) {
|
|
278
|
+
duelRows.push(...input.duelRows);
|
|
279
|
+
openingRows.push(...input.openingRows);
|
|
280
|
+
for (const fact of input.mechanicsRows) {
|
|
281
|
+
const playerMatches = matchesByPlayer.get(fact.steamId64) ?? new Set<string>();
|
|
282
|
+
playerMatches.add(input.matchId);
|
|
283
|
+
matchesByPlayer.set(fact.steamId64, playerMatches);
|
|
284
|
+
const key = `${fact.steamId64}:${fact.weapon}`;
|
|
285
|
+
const teamName = input.teamNamesBySteamId[fact.steamId64] ?? "";
|
|
286
|
+
const prev = aggByKey.get(key);
|
|
287
|
+
if (!prev) {
|
|
288
|
+
aggByKey.set(key, {
|
|
289
|
+
steamId64: fact.steamId64,
|
|
290
|
+
playerName: fact.playerName,
|
|
291
|
+
teamName,
|
|
292
|
+
weapon: fact.weapon,
|
|
293
|
+
killCount: fact.killCount,
|
|
294
|
+
cleanKillCount: fact.cleanKillCount,
|
|
295
|
+
headshotKills: fact.headshotKills,
|
|
296
|
+
cleanHeadshotKills: fact.cleanHeadshotKills,
|
|
297
|
+
shotCount: fact.shotCount,
|
|
298
|
+
burstCount: fact.burstCount,
|
|
299
|
+
firstShotHit: fact.firstShotHit,
|
|
300
|
+
sprayHit: fact.sprayHit,
|
|
301
|
+
counterStrafe: fact.counterStrafe,
|
|
302
|
+
oneTap: fact.oneTap,
|
|
303
|
+
ttkSamples: [...fact.ttkSamplesMs],
|
|
304
|
+
reactionSamples: [...fact.reactionSamplesMs],
|
|
305
|
+
preaimSamples: [...fact.preaimSamplesDeg],
|
|
306
|
+
burstLengthBuckets: { ...fact.burstLengthBuckets }
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
prev.teamName = teamName;
|
|
310
|
+
prev.killCount += fact.killCount;
|
|
311
|
+
prev.cleanKillCount += fact.cleanKillCount;
|
|
312
|
+
prev.headshotKills += fact.headshotKills;
|
|
313
|
+
prev.cleanHeadshotKills += fact.cleanHeadshotKills;
|
|
314
|
+
prev.shotCount += fact.shotCount;
|
|
315
|
+
prev.burstCount += fact.burstCount;
|
|
316
|
+
prev.firstShotHit = sumRate(prev.firstShotHit, fact.firstShotHit);
|
|
317
|
+
prev.sprayHit = fact.sprayHit == null ? prev.sprayHit : prev.sprayHit == null ? fact.sprayHit : sumRate(prev.sprayHit, fact.sprayHit);
|
|
318
|
+
prev.counterStrafe = sumRate(prev.counterStrafe, fact.counterStrafe);
|
|
319
|
+
prev.oneTap = sumRate(prev.oneTap, fact.oneTap);
|
|
320
|
+
prev.ttkSamples.push(...fact.ttkSamplesMs);
|
|
321
|
+
prev.reactionSamples.push(...fact.reactionSamplesMs);
|
|
322
|
+
prev.preaimSamples.push(...fact.preaimSamplesDeg);
|
|
323
|
+
prev.burstLengthBuckets.single += fact.burstLengthBuckets.single;
|
|
324
|
+
prev.burstLengthBuckets.short += fact.burstLengthBuckets.short;
|
|
325
|
+
prev.burstLengthBuckets.medium += fact.burstLengthBuckets.medium;
|
|
326
|
+
prev.burstLengthBuckets.long += fact.burstLengthBuckets.long;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const aggs = [...aggByKey.values()];
|
|
332
|
+
const matchCountByPlayer = new Map([...matchesByPlayer].map(([steamId64, set]) => [steamId64, set.size]));
|
|
333
|
+
const mechanicsRows = aggs
|
|
334
|
+
.map((agg) => mechanicsRow(agg, aggs, matchCountByPlayer))
|
|
335
|
+
.sort((a, b) => b.killCount - a.killCount || b.shotCount - a.shotCount || a.playerName.localeCompare(b.playerName));
|
|
336
|
+
|
|
337
|
+
return duelInsightsModelSchema.parse({
|
|
338
|
+
version: "cs2-demo-analysis-kit/duel-insights-0.1",
|
|
339
|
+
matchCount: facts.length,
|
|
340
|
+
duelRows,
|
|
341
|
+
openingRows,
|
|
342
|
+
mechanicsRows,
|
|
343
|
+
notes: [
|
|
344
|
+
"枪法质量指标只看干净交火:排除第三方伤害、穿烟击杀和穿墙击杀;标题击杀数仍保留真实武器产出。",
|
|
345
|
+
"首发命中率只统计干净交火开火段(造成敌方伤害,或首发开枪时视野锥+LOS 内有活敌)的第一发,排除打门/预开枪。",
|
|
346
|
+
"扫射命中率仅对干净全自动武器、长度≥5 的开火段从第 4 发起统计;手枪/狙击/霰弹显示 —。",
|
|
347
|
+
"急停成功率为干净「移动后停稳」口径:用 duels 连续轨迹判断开枪前 200ms 是否在移动,只把移动样本计入分母;开枪时速度低于该武器判定用最大移动速度约 34% 的停稳阈值记为成功。ZIP 无按键输入与开镜状态;AWP 按常见开镜开枪口径取 100u/s,其他开镜武器暂按基础移速口径展示。",
|
|
348
|
+
"one tap 率仅对可一枪满血终结的武器展示,分母为干净满血(100HP)击杀。",
|
|
349
|
+
"击杀耗时取干净满血击杀中,击杀开火段第一枪到击杀的中位耗时,AK 一枪头可接近 0ms。",
|
|
350
|
+
"反应时间从干净击杀者首发反向找当前连续可见段起点(需 hp>0、未被闪、视野锥内、静态LOS通透、无烟);首发即击杀时用上一帧仍存活的可见状态;窗口首帧已可见、预开枪、可见>1s 的跟踪样本均剔除。",
|
|
351
|
+
"预瞄误差取干净可见段起点前 1~3 帧准星与目标的三维(yaw+pitch)夹角中位,并给出 ≤5° 比例。",
|
|
352
|
+
"百分位标签基于当前聚合范围;未接入固定联赛基线时不输出 A/B/C。"
|
|
353
|
+
]
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function buildDuelInsights(inputs: DuelInsightsInput[], options: DuelInsightsOptions = {}): DuelInsightsModel {
|
|
358
|
+
return buildDuelInsightsFromFacts(inputs.map((input) => extractDuelInsightsFacts(input, options)));
|
|
359
|
+
}
|