@cs2dak/contract 1.0.0 → 1.1.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 +3 -3
- package/src/analysis.ts +11 -5
- package/src/bracket.ts +68 -0
- package/src/cohort.ts +2 -1
- package/src/contract.test.ts +75 -10
- package/src/demo-package.ts +2 -2
- package/src/dependency-boundaries.test.ts +27 -0
- package/src/duel.ts +105 -0
- package/src/event-package.test.ts +48 -0
- package/src/event-package.ts +163 -0
- package/src/evidence.ts +22 -0
- package/src/index.ts +9 -0
- package/src/map-intelligence.ts +202 -0
- package/src/map-role.ts +317 -0
- package/src/player.ts +15 -4
- package/src/radar-field.ts +79 -0
- package/src/scoring.ts +9 -1
- package/src/trails.ts +56 -0
- package/src/upstream.ts +27 -7
- package/src/veto.ts +44 -0
- package/src/workspace.ts +184 -10
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,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cs2dak/contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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
|
-
"cs2-demo-format": "^
|
|
10
|
+
"@rivalhub/rival-rating": "^0.3.0",
|
|
11
|
+
"cs2-demo-format": "^3.1.0",
|
|
12
12
|
"zod": "^3.25.0"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
package/src/analysis.ts
CHANGED
|
@@ -39,6 +39,7 @@ export const playerScoreboardRowSchema = z.object({
|
|
|
39
39
|
steamId64: z.string(),
|
|
40
40
|
name: z.string(),
|
|
41
41
|
teamKey: teamKeySchema,
|
|
42
|
+
indicators: rrIndicatorsSchema,
|
|
42
43
|
kills: z.number().int().nonnegative(),
|
|
43
44
|
deaths: z.number().int().nonnegative(),
|
|
44
45
|
assists: z.number().int().nonnegative(),
|
|
@@ -66,19 +67,22 @@ export const playerScoreboardRowSchema = z.object({
|
|
|
66
67
|
damages: z.enum(["available", "missing"]),
|
|
67
68
|
bombs: z.enum(["available", "missing"])
|
|
68
69
|
}),
|
|
69
|
-
|
|
70
|
+
// rrBase(clamp 前的基础分):被碾压方 KAST/KPR 低、死亡惩罚大时可为负,
|
|
71
|
+
// 不能强制非负,否则一边倒的真实比赛整场导入失败。最终 rr 才被 clamp 到非负。
|
|
72
|
+
ratingSeed: z.number(),
|
|
70
73
|
rr: z.number().nonnegative(),
|
|
71
74
|
rrPercentile: z.number().min(0).max(100),
|
|
72
|
-
/** RR v2
|
|
75
|
+
/** RR v2 账户分。单场为 frozen pro baseline:1.00 = 职业基线。 */
|
|
73
76
|
accountRR: z.number().nonnegative(),
|
|
74
77
|
/** 锚定/clamp 前的原始账户分(调试用)。 */
|
|
75
78
|
accountRRRaw: z.number(),
|
|
76
79
|
/** Combat 击杀项的 context 乘子(1.0 = context 未生效 / 已降级)。 */
|
|
77
80
|
accountCombatContextFactor: z.number(),
|
|
78
|
-
/**
|
|
81
|
+
/** 六账户对 RR 的加权贡献(解释面板用)。 */
|
|
79
82
|
accountBreakdown: z.object({
|
|
80
83
|
combat: z.number(),
|
|
81
84
|
trade: z.number(),
|
|
85
|
+
mapControl: z.number(),
|
|
82
86
|
clutch: z.number(),
|
|
83
87
|
objective: z.number(),
|
|
84
88
|
utility: z.number()
|
|
@@ -157,12 +161,14 @@ export const heatmapPointSchema = z.object({
|
|
|
157
161
|
export const mapViewSchema = z.object({
|
|
158
162
|
name: z.string(),
|
|
159
163
|
radarImageUrl: z.string().nullable(),
|
|
164
|
+
/** 双层地图(de_nuke / de_vertigo)的下层底图;单层地图为 null。 */
|
|
165
|
+
lowerRadarImageUrl: z.string().nullable().optional().default(null),
|
|
160
166
|
calibrated: z.boolean()
|
|
161
167
|
});
|
|
162
168
|
|
|
163
169
|
export const analysisProvenanceSchema = z.object({
|
|
164
170
|
analysisVersion: z.string(),
|
|
165
|
-
sourceSchemaVersion: z.literal("cs2-demo-format/
|
|
171
|
+
sourceSchemaVersion: z.literal("cs2-demo-format/3.0"),
|
|
166
172
|
sourceDemoHash: z.string().nullable(),
|
|
167
173
|
exporter: z.object({ name: z.string(), version: z.string() }),
|
|
168
174
|
parser: z.object({ name: z.string(), version: z.string() }),
|
|
@@ -174,7 +180,7 @@ export const analysisProvenanceSchema = z.object({
|
|
|
174
180
|
|
|
175
181
|
export const analysisBundleSchema = z.object({
|
|
176
182
|
version: z.literal("cs2-demo-analysis-kit/1.0"),
|
|
177
|
-
sourceSchemaVersion: z.literal("cs2-demo-format/
|
|
183
|
+
sourceSchemaVersion: z.literal("cs2-demo-format/3.0"),
|
|
178
184
|
provenance: analysisProvenanceSchema,
|
|
179
185
|
mapName: z.string(),
|
|
180
186
|
tickrate: z.number().int().positive(),
|
package/src/bracket.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 赛事 bracket 的中性数据模型 —— 观看侧(从比赛结果推算)与制作器(从框架槽位)共用同一套
|
|
3
|
+
* 渲染。瑞士轮按战绩组(Buchholz)成列 + 晋级/淘汰终列;淘汰赛按晋级轮次成列。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface BracketCell {
|
|
7
|
+
key: string;
|
|
8
|
+
teamA: string | null;
|
|
9
|
+
teamB: string | null;
|
|
10
|
+
scoreA: number | null;
|
|
11
|
+
scoreB: number | null;
|
|
12
|
+
winner: "A" | "B" | null;
|
|
13
|
+
date: string | null;
|
|
14
|
+
/** 观看侧:点开该场的 demo entry。 */
|
|
15
|
+
entryIds?: string[];
|
|
16
|
+
/** 制作器:待填槽位(点击附加 demo)。 */
|
|
17
|
+
empty?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SwissGroup {
|
|
21
|
+
/** 战绩组标签,如 "1-0"。 */
|
|
22
|
+
record: string;
|
|
23
|
+
matches: BracketCell[];
|
|
24
|
+
/** 制作器:该战绩组可继续添加比赛(携带 slot id)。 */
|
|
25
|
+
addSlotId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SwissColumn {
|
|
29
|
+
round: number;
|
|
30
|
+
groups: SwissGroup[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SwissModel {
|
|
34
|
+
columns: SwissColumn[];
|
|
35
|
+
advanced: { team: string; record: string }[];
|
|
36
|
+
eliminated: { team: string; record: string }[];
|
|
37
|
+
winsTarget: number;
|
|
38
|
+
lossTarget: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ElimColumn {
|
|
42
|
+
round: number;
|
|
43
|
+
label: string;
|
|
44
|
+
matches: BracketCell[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 淘汰赛 bracket 中单个节点的元数据(双败 / GSL lane-aware 布局用)。 */
|
|
48
|
+
export interface ElimNode {
|
|
49
|
+
id: string;
|
|
50
|
+
round: number;
|
|
51
|
+
lane: "winner" | "loser" | "grand" | "single";
|
|
52
|
+
label: string;
|
|
53
|
+
nextWinNodeId?: string | null;
|
|
54
|
+
nextLossNodeId?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ElimModel {
|
|
58
|
+
columns: ElimColumn[];
|
|
59
|
+
/**
|
|
60
|
+
* Lane-aware 节点元数据(可选)。**存在性决定渲染模式**:
|
|
61
|
+
* - 非空 → SVG lane-aware 布局(双败 / GSL),含晋级连线与 lane 分隔
|
|
62
|
+
* - undefined / [] → DOM 列布局(单败 / 旧资产 / 制作器),以 MatchBox 列展示
|
|
63
|
+
*
|
|
64
|
+
* 未来考虑改为显式 `renderMode?: "dom" | "svg-lane"` 以消除隐式联合类型。
|
|
65
|
+
*/
|
|
66
|
+
nodes?: ElimNode[];
|
|
67
|
+
}
|
|
68
|
+
|
package/src/cohort.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const seasonPlayerRowSchema = z.object({
|
|
|
22
22
|
accountBreakdown: z.object({
|
|
23
23
|
combat: z.number(),
|
|
24
24
|
trade: z.number(),
|
|
25
|
+
mapControl: z.number(),
|
|
25
26
|
clutch: z.number(),
|
|
26
27
|
objective: z.number(),
|
|
27
28
|
utility: z.number()
|
|
@@ -47,7 +48,7 @@ export const seasonCohortBundleSchema = z.object({
|
|
|
47
48
|
weightsVersion: z.string(),
|
|
48
49
|
provenance: z.object({
|
|
49
50
|
cohortVersion: z.literal("cs2-demo-analysis-kit/cohort-1.0"),
|
|
50
|
-
sourceSchemaVersion: z.literal("cs2-demo-format/
|
|
51
|
+
sourceSchemaVersion: z.literal("cs2-demo-format/3.0"),
|
|
51
52
|
matches: z.array(z.object({
|
|
52
53
|
matchId: z.string(),
|
|
53
54
|
sourceDemoHash: z.string().nullable()
|
package/src/contract.test.ts
CHANGED
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
mapViewSchema,
|
|
25
25
|
analysisProvenanceSchema,
|
|
26
26
|
analysisBundleSchema,
|
|
27
|
+
evidenceRefSchema,
|
|
28
|
+
matchMapIntelligenceFactsSchema,
|
|
29
|
+
roleDeclarationSchema,
|
|
27
30
|
// cohort
|
|
28
31
|
seasonCohortBundleSchema,
|
|
29
32
|
// workspace
|
|
@@ -35,6 +38,65 @@ import {
|
|
|
35
38
|
// 复用 fixtures
|
|
36
39
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
40
|
|
|
41
|
+
describe("evidenceRefSchema", () => {
|
|
42
|
+
it("requires a human-readable reason", () => {
|
|
43
|
+
expect(evidenceRefSchema.safeParse({ matchId: "m1", roundNumber: 12, reason: "复核该回合的首死" }).success).toBe(true);
|
|
44
|
+
expect(evidenceRefSchema.safeParse({ matchId: "m1", roundNumber: 12 }).success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("roleDeclarationSchema", () => {
|
|
49
|
+
it("accepts portable declarations and excludes storage-only fields", () => {
|
|
50
|
+
expect(roleDeclarationSchema.safeParse({ kind: "main_role", playerKey: "p1", role: "igl", priority: "primary", source: "trusted_metadata", mapName: "de_ancient", provenance: "team roster" }).success).toBe(true);
|
|
51
|
+
expect(roleDeclarationSchema.safeParse({ kind: "weapon_duty", playerKey: "p1", weaponDuty: "secondary_awper", source: "user", provenance: "self report" }).success).toBe(true);
|
|
52
|
+
expect(roleDeclarationSchema.safeParse({ kind: "weapon_duty", playerKey: "p1", weaponDuty: "secondary_awper", priority: "primary", source: "user", provenance: "self report" }).success).toBe(false);
|
|
53
|
+
expect(roleDeclarationSchema.safeParse({ kind: "main_role", playerKey: "p1", role: "igl", priority: "primary", source: "user", provenance: "" }).success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("matchMapIntelligenceFactsSchema", () => {
|
|
58
|
+
const base = {
|
|
59
|
+
analysisVersion: 6 as const,
|
|
60
|
+
matchId: "m1",
|
|
61
|
+
mapName: "de_anubis",
|
|
62
|
+
playerPositionRounds: [{
|
|
63
|
+
analysisVersion: 6 as const,
|
|
64
|
+
matchId: "m1", mapName: "de_anubis", roundNumber: 1, teamKey: "teamA" as const, side: "t" as const,
|
|
65
|
+
playerIndex: 0, steamId64: "76561198000000001", economyType: null, openingWindow: null,
|
|
66
|
+
openingEligibleSeconds: null, openingPositionGroupDwell: [], openingMeanComponentSize: null, openingIsolationSeconds: null,
|
|
67
|
+
openingUtilityUseCount: 0,
|
|
68
|
+
openingPath: [],
|
|
69
|
+
eligibleSeconds: null, positionGroupDwell: [],
|
|
70
|
+
unresolvedCalloutSeconds: null, calloutCoverage: null, meanNearestTeammateDistance: null,
|
|
71
|
+
meanTeamCentroidDistance: null, meanComponentSize: null, isolationSegments: [], rejoinTicks: [], delayedConvergences: [],
|
|
72
|
+
movementSync: null, utilityUseCount: 0, freezeAwpOwnership: null, activeAwpSeconds: null, awpShots: null, awpKills: null,
|
|
73
|
+
availability: { replay: "missing" as const, nav: "missing" as const, callouts: "missing" as const, shots: "missing" as const },
|
|
74
|
+
}],
|
|
75
|
+
teamShapeRounds: [{
|
|
76
|
+
analysisVersion: 6 as const,
|
|
77
|
+
matchId: "m1", mapName: "de_anubis", roundNumber: 1, teamKey: "teamA" as const, side: "t" as const,
|
|
78
|
+
openingWindow: null, openingWindows: [], coverageSeconds: null, windows: [],
|
|
79
|
+
availability: { replay: "missing" as const, nav: "missing" as const, callouts: "missing" as const, shots: "missing" as const },
|
|
80
|
+
}],
|
|
81
|
+
teamAwpRounds: [],
|
|
82
|
+
ctRotationRounds: [],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
it("keeps missing replay facts explicitly null rather than inventing zero", () => {
|
|
86
|
+
expect(matchMapIntelligenceFactsSchema.parse(base).playerPositionRounds[0]?.activeAwpSeconds).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects invalid coverage and malformed shape windows", () => {
|
|
90
|
+
expect(matchMapIntelligenceFactsSchema.safeParse({ ...base, playerPositionRounds: [{ ...base.playerPositionRounds[0], calloutCoverage: 2 }] }).success).toBe(false);
|
|
91
|
+
expect(matchMapIntelligenceFactsSchema.safeParse({ ...base, teamShapeRounds: [{ ...base.teamShapeRounds[0], windows: [{ startTick: 1, endTick: 2, coverageSeconds: 1, componentSizes: [], partition: "", componentPlayerIndices: [] }] }] }).success).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects pre-v6 map facts that omit CT rotation rows instead of defaulting them", () => {
|
|
95
|
+
const { ctRotationRounds: _, ...historic } = base;
|
|
96
|
+
expect(matchMapIntelligenceFactsSchema.safeParse(historic).success).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
38
100
|
const qaReport = {
|
|
39
101
|
ok: true,
|
|
40
102
|
summary: { issueCount: 0, errorCount: 0, warningCount: 0 },
|
|
@@ -77,7 +139,7 @@ const minimalIndicators = {
|
|
|
77
139
|
|
|
78
140
|
const minimalProvenance = {
|
|
79
141
|
analysisVersion: "cs2-demo-analysis-kit/1.0",
|
|
80
|
-
sourceSchemaVersion: "cs2-demo-format/
|
|
142
|
+
sourceSchemaVersion: "cs2-demo-format/3.0" as const,
|
|
81
143
|
sourceDemoHash: null,
|
|
82
144
|
exporter: { name: "cs2dak", version: "0.2.1" },
|
|
83
145
|
parser: { name: "demoparser2", version: "0.1.0" },
|
|
@@ -88,6 +150,7 @@ const minimalScoreboardRow = {
|
|
|
88
150
|
steamId64: "76561198000000001",
|
|
89
151
|
name: "Player A",
|
|
90
152
|
teamKey: "teamA" as const,
|
|
153
|
+
indicators: minimalIndicators,
|
|
91
154
|
kills: 10, deaths: 8, assists: 2,
|
|
92
155
|
adr: 72.5, kast: 66.7, headshotPercent: 40,
|
|
93
156
|
entryKills: 2, tradeKills: 1, awpKills: 2, utilityDamage: 150,
|
|
@@ -108,13 +171,13 @@ const minimalScoreboardRow = {
|
|
|
108
171
|
rr: 1.05, rrPercentile: 55,
|
|
109
172
|
accountRR: 1.1, accountRRRaw: 1.08,
|
|
110
173
|
accountCombatContextFactor: 1.0,
|
|
111
|
-
accountBreakdown: { combat: 0.4, trade: 0.1, clutch: 0.05, objective: 0.05, utility: 0.1 },
|
|
174
|
+
accountBreakdown: { combat: 0.4, trade: 0.1, mapControl: 0, clutch: 0.05, objective: 0.05, utility: 0.1 },
|
|
112
175
|
accountContextStatus: { buyDelta: "available" as const, manState: "available" as const },
|
|
113
176
|
};
|
|
114
177
|
|
|
115
178
|
const minimalBundle = {
|
|
116
179
|
version: "cs2-demo-analysis-kit/1.0" as const,
|
|
117
|
-
sourceSchemaVersion: "cs2-demo-format/
|
|
180
|
+
sourceSchemaVersion: "cs2-demo-format/3.0" as const,
|
|
118
181
|
provenance: minimalProvenance,
|
|
119
182
|
mapName: "de_ancient",
|
|
120
183
|
tickrate: 64,
|
|
@@ -134,7 +197,7 @@ const minimalBundle = {
|
|
|
134
197
|
|
|
135
198
|
const minimalWorkspaceModel = {
|
|
136
199
|
version: "cs2-demo-analysis-kit/workspace-0.1" as const,
|
|
137
|
-
sourceSchemaVersion: "cs2-demo-format/
|
|
200
|
+
sourceSchemaVersion: "cs2-demo-format/3.0" as const,
|
|
138
201
|
title: "Team A vs Team B",
|
|
139
202
|
subtitle: "de_ancient · 10 名选手 · 21 回合",
|
|
140
203
|
scoreline: "13:8",
|
|
@@ -146,6 +209,8 @@ const minimalWorkspaceModel = {
|
|
|
146
209
|
rounds: [],
|
|
147
210
|
players: [],
|
|
148
211
|
economy: [],
|
|
212
|
+
weapons: [],
|
|
213
|
+
duels: { players: [], matrix: [], openings: [] },
|
|
149
214
|
map: {
|
|
150
215
|
view: { name: "de_ancient", radarImageUrl: null, calibrated: false },
|
|
151
216
|
modes: [],
|
|
@@ -157,7 +222,7 @@ const minimalWorkspaceModel = {
|
|
|
157
222
|
sampleRate: null,
|
|
158
223
|
tickrate: null,
|
|
159
224
|
rounds: [],
|
|
160
|
-
capabilities: { hasDefuseKit: false
|
|
225
|
+
capabilities: { hasDefuseKit: false },
|
|
161
226
|
},
|
|
162
227
|
adminQa: qaReport,
|
|
163
228
|
};
|
|
@@ -250,7 +315,7 @@ describe("demoPackageSchema", () => {
|
|
|
250
315
|
it("applies defaults for optional array fields", () => {
|
|
251
316
|
const pkg = demoPackageSchema.parse({
|
|
252
317
|
manifest: {
|
|
253
|
-
schemaVersion: "cs2-demo-format/
|
|
318
|
+
schemaVersion: "cs2-demo-format/3.0",
|
|
254
319
|
exporter: { name: "cs2dak", version: "0.2.1" },
|
|
255
320
|
parser: { name: "demoparser2", version: "0.1.0" },
|
|
256
321
|
demo: { hash: null, sourceFileName: null },
|
|
@@ -292,8 +357,8 @@ describe("demoPackageSchema", () => {
|
|
|
292
357
|
expect(pkg.grenades).toEqual([]);
|
|
293
358
|
expect(pkg.clutches).toEqual([]);
|
|
294
359
|
expect(pkg.shots).toBeUndefined();
|
|
295
|
-
expect(pkg.positions1s).toBeUndefined();
|
|
296
360
|
expect(pkg.replay).toBeUndefined();
|
|
361
|
+
expect(pkg.duels).toBeUndefined();
|
|
297
362
|
});
|
|
298
363
|
});
|
|
299
364
|
|
|
@@ -494,10 +559,10 @@ describe("seasonCohortBundleSchema", () => {
|
|
|
494
559
|
version: "cs2-demo-analysis-kit/cohort-1.0" as const,
|
|
495
560
|
matchCount: 3,
|
|
496
561
|
players: [],
|
|
497
|
-
weightsVersion: "
|
|
562
|
+
weightsVersion: "rr-six-accounts-1.0",
|
|
498
563
|
provenance: {
|
|
499
564
|
cohortVersion: "cs2-demo-analysis-kit/cohort-1.0" as const,
|
|
500
|
-
sourceSchemaVersion: "cs2-demo-format/
|
|
565
|
+
sourceSchemaVersion: "cs2-demo-format/3.0" as const,
|
|
501
566
|
matches: [{ matchId: "abc123", sourceDemoHash: null }],
|
|
502
567
|
},
|
|
503
568
|
};
|
|
@@ -542,7 +607,7 @@ describe("matchWorkspaceModelSchema", () => {
|
|
|
542
607
|
|
|
543
608
|
it("enforces sourceSchemaVersion literal", () => {
|
|
544
609
|
expect(
|
|
545
|
-
matchWorkspaceModelSchema.safeParse({ ...minimalWorkspaceModel, sourceSchemaVersion: "cs2-demo-format/
|
|
610
|
+
matchWorkspaceModelSchema.safeParse({ ...minimalWorkspaceModel, sourceSchemaVersion: "cs2-demo-format/2.0" }).success
|
|
546
611
|
).toBe(false);
|
|
547
612
|
});
|
|
548
613
|
|
package/src/demo-package.ts
CHANGED
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
grenadesSchema,
|
|
14
14
|
clutchesSchema,
|
|
15
15
|
shotsSchema,
|
|
16
|
-
positionsSchema,
|
|
17
16
|
replaySchema,
|
|
17
|
+
duelsSchema,
|
|
18
18
|
} from "cs2-demo-format";
|
|
19
19
|
|
|
20
20
|
export const demoPackageSchema = z.object({
|
|
@@ -31,8 +31,8 @@ export const demoPackageSchema = z.object({
|
|
|
31
31
|
grenades: grenadesSchema.default([]),
|
|
32
32
|
clutches: clutchesSchema.default([]),
|
|
33
33
|
shots: shotsSchema.optional(),
|
|
34
|
-
positions1s: positionsSchema.optional(),
|
|
35
34
|
replay: replaySchema.optional(),
|
|
35
|
+
duels: duelsSchema.optional(),
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
export type DemoPackage = z.infer<typeof demoPackageSchema>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
function readPackageJson(relativePath: string) {
|
|
6
|
+
return JSON.parse(readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8")) as {
|
|
7
|
+
dependencies?: Record<string, string>;
|
|
8
|
+
devDependencies?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("package dependency boundaries", () => {
|
|
13
|
+
it("keeps shared contract dependencies on the current upstream versions", () => {
|
|
14
|
+
const root = readPackageJson("../../../package.json");
|
|
15
|
+
const contract = readPackageJson("../package.json");
|
|
16
|
+
|
|
17
|
+
expect(contract.dependencies?.["cs2-demo-format"]).toBe("^3.1.0");
|
|
18
|
+
expect(contract.dependencies?.["@rivalhub/rival-rating"]).toBe(root.dependencies?.["@rivalhub/rival-rating"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("declares runtime workspace imports as dependencies, not dev-only edges", () => {
|
|
22
|
+
const presentation = readPackageJson("../../presentation/package.json");
|
|
23
|
+
|
|
24
|
+
expect(presentation.dependencies?.["@cs2dak/cohort"]).toBe("workspace:*");
|
|
25
|
+
expect(presentation.devDependencies?.["@cs2dak/cohort"]).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/duel.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { evidenceRefSchema } from "./evidence.js";
|
|
3
|
+
|
|
4
|
+
export const duelEvidenceSchema = evidenceRefSchema;
|
|
5
|
+
|
|
6
|
+
export const duelPointSchema = z.object({
|
|
7
|
+
x: z.number(),
|
|
8
|
+
y: z.number(),
|
|
9
|
+
z: z.number()
|
|
10
|
+
}).nullable();
|
|
11
|
+
|
|
12
|
+
export const duelFinderRowSchema = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
matchId: z.string(),
|
|
15
|
+
mapName: z.string(),
|
|
16
|
+
roundNumber: z.number().int().positive(),
|
|
17
|
+
tick: z.number().int().positive(),
|
|
18
|
+
killerSteamId64: z.string(),
|
|
19
|
+
victimSteamId64: z.string(),
|
|
20
|
+
killerName: z.string(),
|
|
21
|
+
victimName: z.string(),
|
|
22
|
+
weapon: z.string(),
|
|
23
|
+
classification: z.enum(["contested_duel", "suppressed_kill", "caught_off_guard"]),
|
|
24
|
+
hpBucket: z.enum(["full_hp", "low_hp"]),
|
|
25
|
+
thirdParty: z.boolean(),
|
|
26
|
+
fullHealth: z.boolean(),
|
|
27
|
+
victimHealthBefore: z.number().min(0).max(100),
|
|
28
|
+
killerHealthBefore: z.number().min(0).max(100).nullable(),
|
|
29
|
+
ttkMs: z.number().nonnegative().nullable(),
|
|
30
|
+
oneShotKill: z.boolean(),
|
|
31
|
+
evidenceTicks: z.object({
|
|
32
|
+
engagementStartTick: z.number().int().positive(),
|
|
33
|
+
engagementEndTick: z.number().int().positive(),
|
|
34
|
+
killerFirstShotTick: z.number().int().positive().nullable(),
|
|
35
|
+
victimResponseTick: z.number().int().positive().nullable(),
|
|
36
|
+
killTick: z.number().int().positive(),
|
|
37
|
+
windowStartTick: z.number().int().positive().optional(),
|
|
38
|
+
windowEndTick: z.number().int().positive().optional()
|
|
39
|
+
}),
|
|
40
|
+
killerPosition: duelPointSchema,
|
|
41
|
+
victimPosition: duelPointSchema,
|
|
42
|
+
/** 对枪发生时的回合剩余时间标签("1:23"),无 freezeEnd 时为 null。 */
|
|
43
|
+
roundTimeLabel: z.string().nullable(),
|
|
44
|
+
evidence: duelEvidenceSchema
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const openingDuelRowSchema = duelFinderRowSchema.extend({
|
|
48
|
+
attackerCallout: z.string().nullable(),
|
|
49
|
+
victimCallout: z.string().nullable()
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const mechanicsMetricSchema = z.object({
|
|
53
|
+
key: z.string(),
|
|
54
|
+
label: z.string(),
|
|
55
|
+
/** 主数值;无有效样本时为 null(UI 显示 —)。 */
|
|
56
|
+
value: z.number().nullable(),
|
|
57
|
+
unit: z.string().optional(),
|
|
58
|
+
/** 证据分子/分母(命中率类指标)。 */
|
|
59
|
+
successes: z.number().int().nonnegative().optional(),
|
|
60
|
+
attempts: z.number().int().nonnegative().optional(),
|
|
61
|
+
/** 中位类指标的样本数。 */
|
|
62
|
+
sampleSize: z.number().int().nonnegative().optional(),
|
|
63
|
+
/** 次级证据行,如「27/44 ≤5°」。 */
|
|
64
|
+
detail: z.string().optional(),
|
|
65
|
+
percentileLabel: z.string().nullable()
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const playerMechanicsRowSchema = z.object({
|
|
69
|
+
steamId64: z.string(),
|
|
70
|
+
playerName: z.string(),
|
|
71
|
+
teamName: z.string(),
|
|
72
|
+
weapon: z.string(),
|
|
73
|
+
killCount: z.number().int().nonnegative(),
|
|
74
|
+
shotCount: z.number().int().nonnegative(),
|
|
75
|
+
burstCount: z.number().int().nonnegative(),
|
|
76
|
+
metrics: z.array(mechanicsMetricSchema),
|
|
77
|
+
burstLengthBuckets: z.object({
|
|
78
|
+
single: z.number().int().nonnegative(),
|
|
79
|
+
short: z.number().int().nonnegative(),
|
|
80
|
+
medium: z.number().int().nonnegative(),
|
|
81
|
+
long: z.number().int().nonnegative()
|
|
82
|
+
}),
|
|
83
|
+
firingPatternRatio: z.object({
|
|
84
|
+
tap: z.number().min(0).max(100),
|
|
85
|
+
burst: z.number().min(0).max(100),
|
|
86
|
+
spray: z.number().min(0).max(100)
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const duelInsightsModelSchema = z.object({
|
|
91
|
+
version: z.literal("cs2-demo-analysis-kit/duel-insights-0.1"),
|
|
92
|
+
matchCount: z.number().int().nonnegative(),
|
|
93
|
+
duelRows: z.array(duelFinderRowSchema),
|
|
94
|
+
openingRows: z.array(openingDuelRowSchema),
|
|
95
|
+
mechanicsRows: z.array(playerMechanicsRowSchema),
|
|
96
|
+
notes: z.array(z.string())
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export type DuelEvidence = z.infer<typeof duelEvidenceSchema>;
|
|
100
|
+
export type DuelPoint = z.infer<typeof duelPointSchema>;
|
|
101
|
+
export type DuelFinderRow = z.infer<typeof duelFinderRowSchema>;
|
|
102
|
+
export type OpeningDuelRow = z.infer<typeof openingDuelRowSchema>;
|
|
103
|
+
export type MechanicsMetric = z.infer<typeof mechanicsMetricSchema>;
|
|
104
|
+
export type PlayerMechanicsRow = z.infer<typeof playerMechanicsRowSchema>;
|
|
105
|
+
export type DuelInsightsModel = z.infer<typeof duelInsightsModelSchema>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { eventPackageSchema } from "./event-package.js";
|
|
3
|
+
|
|
4
|
+
const valid = {
|
|
5
|
+
version: "cs2-demo-analysis-kit/event-package-1.0",
|
|
6
|
+
source: "manual",
|
|
7
|
+
exportedAt: "2026-06-20T00:00:00Z",
|
|
8
|
+
event: { slug: "cologne-major-2026", name: "IEM Cologne Major 2026", kind: "major", stages: [{ key: "playoffs", name: "淘汰赛", type: "single_elim", teamCount: 8, advanceCount: 1 }] },
|
|
9
|
+
teams: [{ key: "a", name: "Team A", players: [] }, { key: "b", name: "Team B", players: [] }],
|
|
10
|
+
series: [{ key: "final", stage: "playoffs", round: 1, format: "bo5", teamAKey: "a", teamBKey: "b", maps: [] }],
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
describe("eventPackageSchema", () => {
|
|
14
|
+
it("accepts a valid event package", () => {
|
|
15
|
+
expect(eventPackageSchema.parse(valid).event.slug).toBe("cologne-major-2026");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("rejects unknown team and stage references", () => {
|
|
19
|
+
const result = eventPackageSchema.safeParse({ ...valid, series: [{ ...valid.series[0], teamBKey: "missing", stage: "missing" }] });
|
|
20
|
+
expect(result.success).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects duplicate keys and same-team series", () => {
|
|
24
|
+
expect(eventPackageSchema.safeParse({ ...valid, teams: [valid.teams[0], valid.teams[0]] }).success).toBe(false);
|
|
25
|
+
expect(eventPackageSchema.safeParse({ ...valid, series: [{ ...valid.series[0], teamBKey: "a" }] }).success).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("validates bracket nodes and series node references", () => {
|
|
29
|
+
const bracket = {
|
|
30
|
+
...valid,
|
|
31
|
+
event: { ...valid.event, stages: [{ ...valid.event.stages[0], bracketNodes: [
|
|
32
|
+
{ id: "semi", label: "半决赛", round: 1, lane: "single", nextWinNodeId: "final" },
|
|
33
|
+
{ id: "final", label: "决赛", round: 2, lane: "single", nextWinNodeId: null },
|
|
34
|
+
] }] },
|
|
35
|
+
series: [{ ...valid.series[0], bracketNodeId: "final" }],
|
|
36
|
+
};
|
|
37
|
+
expect(eventPackageSchema.safeParse(bracket).success).toBe(true);
|
|
38
|
+
expect(eventPackageSchema.safeParse({ ...bracket, series: [{ ...bracket.series[0], bracketNodeId: "missing" }] }).success).toBe(false);
|
|
39
|
+
expect(eventPackageSchema.safeParse({ ...bracket, event: { ...bracket.event, stages: [{ ...bracket.event.stages[0], bracketNodes: [{ id: "semi", label: "半决赛", round: 1, nextWinNodeId: "missing" }] }] } }).success).toBe(false);
|
|
40
|
+
expect(eventPackageSchema.safeParse({ ...bracket, event: { ...bracket.event, stages: [{ ...bracket.event.stages[0], bracketNodes: [{ id: "self", label: "自环", round: 1, nextWinNodeId: "self" }] }] } }).success).toBe(false);
|
|
41
|
+
expect(eventPackageSchema.safeParse({ ...bracket, event: { ...bracket.event, stages: [{ ...bracket.event.stages[0], bracketNodes: [
|
|
42
|
+
{ id: "later", label: "后轮", round: 2, nextWinNodeId: "early" }, { id: "early", label: "前轮", round: 1 },
|
|
43
|
+
] }] } }).success).toBe(false);
|
|
44
|
+
expect(eventPackageSchema.safeParse({ ...bracket, event: { ...bracket.event, stages: [{ ...bracket.event.stages[0], bracketNodes: [
|
|
45
|
+
{ id: "a", label: "A", round: 1, nextWinNodeId: "b" }, { id: "b", label: "B", round: 2, nextWinNodeId: "a" },
|
|
46
|
+
] }] } }).success).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|