@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 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.0.0",
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.1.0",
11
- "cs2-demo-format": "^2.2.0",
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
- ratingSeed: z.number().nonnegative(),
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 账户分。锚定后:1.00 = 本场联赛均值(per-match league mean)。 */
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
- /** 五账户对 RR 的加权贡献(解释面板用;和为 accountRRRaw − intercept)。 */
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/2.0"),
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/2.0"),
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/2.0"),
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()
@@ -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/2.0" as const,
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/2.0" as const,
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/2.0" as const,
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, hasBombPosition: 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/2.0",
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: "value-accounts-v2-lite@0.1.0",
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/2.0" as const,
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/3.0" }).success
610
+ matchWorkspaceModelSchema.safeParse({ ...minimalWorkspaceModel, sourceSchemaVersion: "cs2-demo-format/2.0" }).success
546
611
  ).toBe(false);
547
612
  });
548
613
 
@@ -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
+ });