@cs2dak/contract 0.2.1 → 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/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
+ });
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ import { seriesVetoSchema } from "./veto.js";
3
+
4
+ export const eventBracketNodeSchema = z.object({
5
+ id: z.string().min(1),
6
+ label: z.string().min(1),
7
+ round: z.number().int().positive(),
8
+ lane: z.enum(["single", "winner", "loser", "grand"]).default("single"),
9
+ nextWinNodeId: z.string().nullable().optional(),
10
+ nextLossNodeId: z.string().nullable().optional(),
11
+ });
12
+
13
+ export const eventStageSchema = z.object({
14
+ key: z.string().min(1),
15
+ name: z.string().min(1),
16
+ type: z.enum(["round_robin", "swiss", "single_elim", "double_elim", "gsl_group"]),
17
+ teamCount: z.number().int().positive(),
18
+ advanceCount: z.number().int().nonnegative().default(0),
19
+ matchFormat: z.enum(["bo1", "bo3", "bo5"]).optional(),
20
+ finalFormat: z.enum(["bo3", "bo5"]).optional(),
21
+ bracketNodes: z.array(eventBracketNodeSchema).optional(),
22
+ });
23
+
24
+ export const eventTeamSchema = z.object({
25
+ key: z.string().min(1),
26
+ name: z.string().min(1),
27
+ players: z.array(z.object({
28
+ name: z.string().min(1),
29
+ steamId64: z.string().nullable().optional(),
30
+ })).default([]),
31
+ });
32
+
33
+ export const eventMapSchema = z.object({
34
+ order: z.number().int().positive(),
35
+ mapName: z.string().min(1),
36
+ pickedBy: z.enum(["teamA", "teamB"]).nullable().optional(),
37
+ teamAStartSide: z.enum(["t", "ct"]).nullable().optional(),
38
+ scoreA: z.number().int().nonnegative().nullable().optional(),
39
+ scoreB: z.number().int().nonnegative().nullable().optional(),
40
+ demoHint: z.object({
41
+ fileName: z.string().nullable().optional(),
42
+ sha256: z.string().nullable().optional(),
43
+ }).nullable().optional(),
44
+ });
45
+
46
+ export const rawDemoHintSchema = z.object({
47
+ downloadUrl: z.string().url().nullable().optional(),
48
+ fileName: z.string().nullable().optional(),
49
+ }).nullable().optional();
50
+
51
+ export const eventSeriesSchema = z.object({
52
+ key: z.string().min(1),
53
+ stage: z.string().nullable().optional(),
54
+ round: z.number().int().nonnegative().nullable().optional(),
55
+ entryRound: z.string().nullable().optional(),
56
+ bracketNodeId: z.string().nullable().optional(),
57
+ status: z.enum(["scheduled", "in_progress", "finished", "cancelled"]).default("scheduled"),
58
+ teamARecordBefore: z.string().nullable().optional(),
59
+ teamBRecordBefore: z.string().nullable().optional(),
60
+ format: z.enum(["bo1", "bo3", "bo5"]),
61
+ matchUrl: z.string().url().nullable().optional(), // 该系列来源页(HLTV match 页),展示层可链回
62
+ rawDemoHint: rawDemoHintSchema, // 可选:打包侧已知的原始 demo 包直链(如 HLTV r2-demos rar)
63
+ teamAKey: z.string().min(1),
64
+ teamBKey: z.string().min(1),
65
+ scoreA: z.number().int().nonnegative().nullable().optional(),
66
+ scoreB: z.number().int().nonnegative().nullable().optional(),
67
+ scheduledAt: z.string().datetime().nullable().optional(),
68
+ completedAt: z.string().datetime().nullable().optional(),
69
+ veto: seriesVetoSchema.nullable().optional(),
70
+ maps: z.array(eventMapSchema).default([]),
71
+ });
72
+
73
+ export const eventPackageSchema = z.object({
74
+ version: z.literal("cs2-demo-analysis-kit/event-package-1.0"),
75
+ source: z.enum(["rivalhub", "manual", "r2"]),
76
+ exportedAt: z.string().datetime(),
77
+ event: z.object({
78
+ slug: z.string().min(1),
79
+ name: z.string().min(1),
80
+ kind: z.string().min(1),
81
+ sourceUrl: z.string().url().optional(), // 赛事来源页(如 HLTV results 页),展示层可链回
82
+ group: z.string().optional(), // 共享归组键:同一赛事按 stage 拆多包时共用,Gallery 折叠
83
+ stages: z.array(eventStageSchema).default([]),
84
+ }),
85
+ teams: z.array(eventTeamSchema),
86
+ series: z.array(eventSeriesSchema),
87
+ }).superRefine((value, context) => {
88
+ const teamKeys = new Set(value.teams.map((team) => team.key));
89
+ const stageKeys = new Set(value.event.stages.map((stage) => stage.key));
90
+ const seriesKeys = new Set(value.series.map((series) => series.key));
91
+ if (teamKeys.size !== value.teams.length) context.addIssue({ code: z.ZodIssueCode.custom, path: ["teams"], message: "队伍 key 必须唯一" });
92
+ if (stageKeys.size !== value.event.stages.length) context.addIssue({ code: z.ZodIssueCode.custom, path: ["event", "stages"], message: "阶段 key 必须唯一" });
93
+ if (seriesKeys.size !== value.series.length) context.addIssue({ code: z.ZodIssueCode.custom, path: ["series"], message: "系列赛 key 必须唯一" });
94
+ for (const [stageIndex, stage] of value.event.stages.entries()) {
95
+ const nodes = stage.bracketNodes ?? [];
96
+ const nodeIds = new Set(nodes.map((node) => node.id));
97
+ if (nodeIds.size !== nodes.length) context.addIssue({ code: z.ZodIssueCode.custom, path: ["event", "stages", stageIndex, "bracketNodes"], message: "bracket 节点 id 必须唯一" });
98
+ for (const [nodeIndex, node] of nodes.entries()) {
99
+ for (const target of [node.nextWinNodeId, node.nextLossNodeId]) {
100
+ if (target && !nodeIds.has(target)) context.addIssue({ code: z.ZodIssueCode.custom, path: ["event", "stages", stageIndex, "bracketNodes", nodeIndex], message: "bracket 晋级关系引用了不存在的节点" });
101
+ const targetNode = target ? nodes.find((candidate) => candidate.id === target) : null;
102
+ if (targetNode && targetNode.round <= node.round) context.addIssue({ code: z.ZodIssueCode.custom, path: ["event", "stages", stageIndex, "bracketNodes", nodeIndex], message: "bracket 晋级关系必须指向后续轮次" });
103
+ }
104
+ }
105
+ const visiting = new Set<string>();
106
+ const visited = new Set<string>();
107
+ const visit = (id: string): boolean => {
108
+ if (visiting.has(id)) return true;
109
+ if (visited.has(id)) return false;
110
+ visiting.add(id);
111
+ const node = nodes.find((candidate) => candidate.id === id);
112
+ const cyclic = [node?.nextWinNodeId, node?.nextLossNodeId].some((target) => target ? visit(target) : false);
113
+ visiting.delete(id);
114
+ visited.add(id);
115
+ return cyclic;
116
+ };
117
+ if (nodes.some((node) => visit(node.id))) context.addIssue({ code: z.ZodIssueCode.custom, path: ["event", "stages", stageIndex, "bracketNodes"], message: "bracket 晋级关系不能成环" });
118
+ }
119
+ for (const [index, series] of value.series.entries()) {
120
+ if (!teamKeys.has(series.teamAKey) || !teamKeys.has(series.teamBKey)) {
121
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["series", index], message: "series 引用了不存在的队伍" });
122
+ }
123
+ if (series.stage && !stageKeys.has(series.stage)) {
124
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["series", index, "stage"], message: "series 引用了不存在的阶段" });
125
+ }
126
+ if (series.bracketNodeId) {
127
+ const stage = value.event.stages.find((candidate) => candidate.key === series.stage);
128
+ if (!stage?.bracketNodes?.some((node) => node.id === series.bracketNodeId)) context.addIssue({ code: z.ZodIssueCode.custom, path: ["series", index, "bracketNodeId"], message: "series 引用了不存在的 bracket 节点" });
129
+ }
130
+ if (series.teamAKey === series.teamBKey) {
131
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["series", index], message: "系列赛双方不能是同一队" });
132
+ }
133
+ if (new Set(series.maps.map((map) => map.order)).size !== series.maps.length) {
134
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["series", index, "maps"], message: "系列赛地图顺序必须唯一" });
135
+ }
136
+ }
137
+ });
138
+
139
+ export type EventPackage = z.infer<typeof eventPackageSchema>;
140
+ export type EventStage = z.infer<typeof eventStageSchema>;
141
+ export type EventBracketNode = z.infer<typeof eventBracketNodeSchema>;
142
+ export type EventTeam = z.infer<typeof eventTeamSchema>;
143
+ export type EventSeries = z.infer<typeof eventSeriesSchema>;
144
+ export type RawDemoHint = z.infer<typeof rawDemoHintSchema>;
145
+
146
+ export const eventsManifestSchema = z.object({
147
+ version: z.literal("cs2-demo-analysis-kit/events-manifest-1.0"),
148
+ generatedAt: z.string().datetime(),
149
+ events: z.array(z.object({
150
+ slug: z.string().min(1),
151
+ name: z.string().min(1),
152
+ size: z.number().int().nonnegative(),
153
+ sha256: z.string().regex(/^[a-fA-F0-9]{64}$/),
154
+ urls: z.array(z.string().url()).min(1),
155
+ packageVersion: z.string().min(1),
156
+ // 展示层可选字段(向后兼容;旧 manifest 不带也合法):
157
+ description: z.string().optional(), // 卡片副标题文案
158
+ builtin: z.boolean().optional(), // 首发/内置:进入页面提示一键下载
159
+ group: z.string().optional(), // UI 归组键(同 group 折叠成一个赛事、展开见各阶段,如 iem-cologne-major-2026)
160
+ })),
161
+ });
162
+
163
+ export type EventsManifest = z.infer<typeof eventsManifestSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 可定位的分析证据。
5
+ *
6
+ * 这是跨 core/cohort/presentation 的纯数据合同;它只说明要看哪一段比赛、
7
+ * 以及这一段为何与结果有关,不携带 Studio 导航或持久化语义。
8
+ */
9
+ export const evidenceRoleSchema = z.enum(["example", "supporting", "counterexample"]);
10
+
11
+ export const evidenceRefSchema = z.object({
12
+ matchId: z.string().min(1),
13
+ roundNumber: z.number().int().positive(),
14
+ tick: z.number().int().positive().optional(),
15
+ eventKey: z.string().min(1).optional(),
16
+ areaKey: z.string().min(1).optional(),
17
+ reason: z.string().min(1),
18
+ role: evidenceRoleSchema.optional(),
19
+ });
20
+
21
+ export type EvidenceRole = z.infer<typeof evidenceRoleSchema>;
22
+ export type EvidenceRef = z.infer<typeof evidenceRefSchema>;