@cs2dak/core 0.2.1 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cs2dak/core",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "@rivalhub/rival-rating": "^0.1.0",
11
11
  "jszip": "^3.10.1",
12
- "@cs2dak/contract": "0.2.1"
12
+ "@cs2dak/contract": "1.0.0"
13
13
  },
14
14
  "files": [
15
15
  "src"
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { EconomyPoint } from "@cs2dak/contract";
3
- import { buildEconomyConversion, economyLabelCn } from "./economy";
3
+ import { buildEconomyConversion } from "./economy";
4
4
 
5
5
  function pt(p: Partial<EconomyPoint> & Pick<EconomyPoint, "teamAEconomy" | "teamBEconomy" | "winnerTeamKey">): EconomyPoint {
6
6
  return {
@@ -35,17 +35,3 @@ describe("buildEconomyConversion", () => {
35
35
  expect(buildEconomyConversion([])).toEqual({ teamA: {}, teamB: {} });
36
36
  });
37
37
  });
38
-
39
- describe("economyLabelCn", () => {
40
- it("maps known economy types to Chinese labels", () => {
41
- expect(economyLabelCn("full")).toBe("全枪全弹");
42
- expect(economyLabelCn("ECO")).toBe("纯ECO");
43
- // conversion 与 full 同义(长枪局),不单独区分。
44
- expect(economyLabelCn("conversion")).toBe(economyLabelCn("full"));
45
- });
46
-
47
- it("passes through unknowns and empties", () => {
48
- expect(economyLabelCn(null)).toBe("");
49
- expect(economyLabelCn("mystery")).toBe("mystery");
50
- });
51
- });
package/src/economy.ts CHANGED
@@ -16,25 +16,6 @@ export interface MatchEconomyConversion {
16
16
  teamB: EconomyConversion;
17
17
  }
18
18
 
19
- const ECONOMY_LABELS_CN: Record<string, string> = {
20
- pistol: "手枪局",
21
- eco: "纯ECO",
22
- semi: "半起",
23
- force: "强起",
24
- full: "全枪全弹",
25
- // conversion = 长枪局,与 full 同义,不单独区分。
26
- conversion: "全枪全弹",
27
- };
28
-
29
- /**
30
- * 经济类型中文标签(转化率面板 / 榜单展示用)。
31
- * 统一了 RivalHub `economy-series.ts` 的 `economyLabelCn`。未知值原样返回。
32
- */
33
- export function economyLabelCn(type: string | null | undefined): string {
34
- if (!type) return "";
35
- return ECONOMY_LABELS_CN[type.toLowerCase()] ?? type;
36
- }
37
-
38
19
  function tally(
39
20
  acc: Map<string, { played: number; won: number }>,
40
21
  type: TeamEconomyType,
package/src/index.test.ts CHANGED
@@ -3,69 +3,34 @@ import { readFile } from "node:fs/promises";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  analyzeDemoPackage,
6
- buildMatchWorkspaceModel,
7
- buildDemoViewModel,
8
6
  computeAccountRatingsV2,
9
7
  deriveAccountSignalsV2,
8
+ derivePlayerWeaponHighlights,
10
9
  deriveRRIndicators,
11
10
  loadDemoPackageFromZip
12
11
  } from "./index";
13
12
 
14
13
  describe("analyzeDemoPackage", () => {
15
- it("builds a match workspace model with replay and user-facing modules from a strict v2 export", async () => {
16
- const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
17
- const pkg = await loadDemoPackageFromZip(zip);
18
- const workspace = buildMatchWorkspaceModel(pkg);
19
-
20
- expect(workspace.version).toBe("cs2-demo-analysis-kit/workspace-0.1");
21
- expect(workspace.title).toBe("Team A vs Team B");
22
- expect(workspace.tabs.map((tab) => tab.key)).toEqual(["overview", "rounds", "players", "economy", "map", "replay"]);
23
- expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("qa");
24
- expect(workspace.overview.kpis.map((kpi) => kpi.key)).not.toContain("economySwing");
25
- expect(workspace.rounds).toHaveLength(21);
26
- expect(workspace.rounds[0]?.events.some((event) => event.type === "kill")).toBe(true);
27
- expect(workspace.players).toHaveLength(10);
28
- expect(workspace.players[0]?.rrBreakdown.length).toBe(5);
29
- expect(workspace.map.modes.map((mode) => mode.key)).toEqual(["death", "kill", "grenade", "bomb", "position"]);
30
- expect(workspace.replay.available).toBe(true);
31
- expect(workspace.replay.rounds.length).toBeGreaterThan(0);
32
- expect(workspace.replay.rounds[0]?.players[0]?.frames.length).toBeGreaterThan(0);
33
- expect(workspace.replay.capabilities.hasDefuseKit).toBe(true);
34
- expect(workspace.replay.capabilities.hasBombPosition).toBe(false);
35
- expect(workspace.replay.rounds.some((round) => round.players.some((player) => player.frames.some((frame) => frame.weapon && /^\d+$/.test(frame.weapon))))).toBe(false);
36
- expect(workspace.overview.story.length).toBeGreaterThan(0);
37
- // 开场 beat 必出:含地图显示名 + 比分,且用胜负动词之一描述结果。
38
- expect(workspace.overview.story[0]).toContain("Ancient");
39
- expect(workspace.overview.story[0]).toContain("13:8");
40
- expect(workspace.overview.story[0]).toMatch(/碾压|险胜|拿下/);
41
- expect(workspace.overview.story.join("\n")).not.toContain("打成");
42
- expect(workspace.adminQa.summary.errorCount).toBe(0);
43
- });
44
-
45
14
  it("builds reusable analysis and view-model artifacts from a strict v2 export", async () => {
46
15
  const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
47
16
  const pkg = await loadDemoPackageFromZip(zip);
48
17
  const bundle = analyzeDemoPackage(pkg);
49
- const viewModel = buildDemoViewModel(bundle);
50
-
51
18
  expect(bundle.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
52
- expect(bundle.version).toBe("cs2-demo-analysis-kit/0.2");
19
+ expect(bundle.version).toBe("cs2-demo-analysis-kit/1.0");
20
+ expect(bundle.provenance.sourceSchemaVersion).toBe("cs2-demo-format/2.0");
21
+ expect(bundle.provenance.ratingVersions.rr).toBeTruthy();
53
22
  expect(bundle.scoreboard).toHaveLength(10);
54
23
  expect(bundle.scoreboard[0]?.rr).toBeGreaterThan(0);
55
- expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(21);
56
- expect(bundle.playerRoundFacts).toHaveLength(210);
57
- expect(bundle.economy).toHaveLength(21);
58
- expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(142);
59
- expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(94);
60
- expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(142);
24
+ expect(bundle.playerIndicators[0]?.indicators.totalRounds).toBe(16);
25
+ expect(bundle.playerRoundFacts).toHaveLength(160);
26
+ expect(bundle.economy).toHaveLength(16);
27
+ expect(bundle.timeline.filter((event) => event.type === "kill")).toHaveLength(119);
28
+ expect(bundle.timeline.filter((event) => event.type === "bomb")).toHaveLength(51);
29
+ expect(bundle.heatmap.filter((point) => point.kind === "death")).toHaveLength(119);
61
30
  expect(bundle.timeline.some((event) => event.type === "kill")).toBe(true);
62
31
  expect(bundle.heatmap.some((point) => point.kind === "death")).toBe(true);
63
32
  expect(bundle.timeline.find((event) => event.type === "round-end")?.clockPhase).toBe("round-end");
64
33
  expect(bundle.timeline.find((event) => event.type === "kill")?.clockLabel).toMatch(/^\d:\d{2}$/);
65
- expect(viewModel.scoreline).toBe("13:8");
66
- expect(viewModel.map.name).toBe("de_ancient");
67
- expect(viewModel.map.radarImageUrl).toBe("./maps/radars/de_ancient.png");
68
- expect(viewModel.qa.summary.errorCount).toBe(0);
69
34
  });
70
35
 
71
36
  it("derives value-account signals and computes v2 RR from the strict v2 fixture", async () => {
@@ -76,10 +41,10 @@ describe("analyzeDemoPackage", () => {
76
41
 
77
42
  expect(signals).toHaveLength(10);
78
43
  expect(ratings).toHaveLength(10);
79
- expect(signals[0]?.rounds).toBe(21);
80
- expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 0, even: 2, advantage: 5 });
81
- expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 1, even: 5, manUp: 1 });
82
- expect(signals[0]?.trade.tradedOpeningDeaths).toBe(1);
44
+ expect(signals[0]?.rounds).toBe(16);
45
+ expect(signals[0]?.combat.killsByBuyDelta).toEqual({ disadvantage: 2, even: 3, advantage: 8 });
46
+ expect(signals[0]?.combat.killsByManState).toEqual({ manDown: 2, even: 5, manUp: 6 });
47
+ expect(signals[0]?.trade.tradedOpeningDeaths).toBe(0);
83
48
  expect(ratings[0]?.rr.model).toBe("value-accounts-v2-lite");
84
49
  expect(ratings[0]?.rr.rr).toBeGreaterThan(0);
85
50
  });
@@ -152,11 +117,11 @@ describe("analyzeDemoPackage", () => {
152
117
  const bundle = analyzeDemoPackage(pkg);
153
118
  const row = bundle.scoreboard[0]!;
154
119
 
155
- expect(row.combatDeathCount).toBe(11);
120
+ expect(row.combatDeathCount).toBe(9);
156
121
  expect(row.bombDeathCount).toBe(0);
157
- expect(row.bombPlantCount).toBe(4);
122
+ expect(row.bombPlantCount).toBe(0);
158
123
  expect(row.noScopeKillCount).toBe(0);
159
- expect(row.throughSmokeKillCount).toBe(2);
124
+ expect(row.throughSmokeKillCount).toBe(0);
160
125
  expect(row.fieldAvailability).toEqual({
161
126
  playerStats: "available",
162
127
  economy: "available",
@@ -168,6 +133,28 @@ describe("analyzeDemoPackage", () => {
168
133
  expect(row.confidence).toBeCloseTo(0.917, 3);
169
134
  });
170
135
 
136
+ it("derives reusable per-player weapon kill distribution and highlight facts", async () => {
137
+ const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
138
+ const pkg = await loadDemoPackageFromZip(zip);
139
+ const facts = derivePlayerWeaponHighlights(pkg);
140
+ const bundle = analyzeDemoPackage(pkg);
141
+
142
+ expect(facts).toHaveLength(pkg.players.length);
143
+ expect(bundle.playerWeaponHighlights).toEqual(facts);
144
+ expect(facts.every((row) => row.weapons.reduce((sum, weapon) => sum + weapon.kills, 0) === row.totalKills)).toBe(true);
145
+ expect(facts.flatMap((row) => row.weapons).some((weapon) => weapon.weapon === "ak47")).toBe(true);
146
+ expect(facts.flatMap((row) => row.weapons).every((weapon) =>
147
+ weapon.headshotKills <= weapon.kills
148
+ && weapon.tradeKills <= weapon.kills
149
+ && weapon.noScopeKills <= weapon.kills
150
+ && weapon.throughSmokeKills <= weapon.kills
151
+ && weapon.wallbangKills <= weapon.kills
152
+ )).toBe(true);
153
+ expect(facts.every((row) => row.highlights.wallbangKills != null)).toBe(true);
154
+ expect(facts.every((row) => row.highlights.noScopeKills != null)).toBe(true);
155
+ expect(facts.every((row) => row.highlights.throughSmokeKills != null)).toBe(true);
156
+ });
157
+
171
158
  it("emits null context buckets (not zero) when the data source is missing", async () => {
172
159
  const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
173
160
  const pkg = await loadDemoPackageFromZip(zip);
package/src/index.ts CHANGED
@@ -4,19 +4,21 @@ import { buildQaReport } from "./qa.js";
4
4
  import { buildPlayerRoundFacts, buildPlayerIndicators, buildScoreboard } from "./scoreboard.js";
5
5
  import { computeAccountRatingsV2 } from "./signals.js";
6
6
  import { buildTimeline, buildEconomy, buildHeatmap } from "./timeline.js";
7
+ import { buildPlayerWeaponHighlights } from "./weapon-highlights.js";
7
8
 
8
9
  export { loadDemoPackageFromZip } from "./loader.js";
9
10
  export { normalizeDemoPackage } from "./normalize.js";
10
11
  export { deriveAccountSignalsV2, computeAccountRatingsV2 } from "./signals.js";
11
12
  export { deriveRRIndicators } from "./scoreboard.js";
12
- export { buildDemoViewModel, buildMatchWorkspaceModel } from "./workspace.js";
13
- export { displayWeaponName } from "./weapons.js";
14
- export { buildEconomyConversion, economyLabelCn } from "./economy.js";
13
+ export { derivePlayerWeaponHighlights } from "./weapon-highlights.js";
14
+ export { buildEconomyConversion } from "./economy.js";
15
+ export { buildTeamSideWinRates } from "./side-win-rate.js";
15
16
  export type {
16
17
  EconomyTypeStats,
17
18
  EconomyConversion,
18
19
  MatchEconomyConversion,
19
20
  } from "./economy.js";
21
+ export type { SideWinRateStats, TeamSideWinRates } from "./side-win-rate.js";
20
22
 
21
23
  export function analyzeDemoPackage(input: unknown): AnalysisBundle {
22
24
  const pkg = normalizeDemoPackage(input);
@@ -25,13 +27,25 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
25
27
  const playerIndicators = buildPlayerIndicators(pkg, playerRoundFacts);
26
28
  const accountRatings = computeAccountRatingsV2(pkg);
27
29
  const scoreboard = buildScoreboard(pkg, playerIndicators, accountRatings);
30
+ const playerWeaponHighlights = buildPlayerWeaponHighlights(pkg);
28
31
  const timeline = buildTimeline(pkg);
29
32
  const economy = buildEconomy(pkg);
30
33
  const heatmap = buildHeatmap(pkg);
31
34
 
32
35
  return analysisBundleSchema.parse({
33
- version: "cs2-demo-analysis-kit/0.2",
36
+ version: "cs2-demo-analysis-kit/1.0",
34
37
  sourceSchemaVersion: pkg.manifest.schemaVersion,
38
+ provenance: {
39
+ analysisVersion: "cs2-demo-analysis-kit/1.0",
40
+ sourceSchemaVersion: pkg.manifest.schemaVersion,
41
+ sourceDemoHash: pkg.manifest.demo?.hash ?? null,
42
+ exporter: pkg.manifest.exporter,
43
+ parser: pkg.manifest.parser,
44
+ ratingVersions: {
45
+ rr: playerIndicators[0]?.rr.weightsVersion ?? null,
46
+ valueAccounts: accountRatings[0]?.rr.weightsVersion ?? null
47
+ }
48
+ },
35
49
  mapName: pkg.match.mapName,
36
50
  tickrate: pkg.match.tickrate,
37
51
  teams: {
@@ -39,6 +53,7 @@ export function analyzeDemoPackage(input: unknown): AnalysisBundle {
39
53
  teamB: { name: pkg.match.teamB.name ?? "Team B", score: pkg.match.teamB.score }
40
54
  },
41
55
  scoreboard,
56
+ playerWeaponHighlights,
42
57
  playerIndicators,
43
58
  playerRoundFacts,
44
59
  timeline,
package/src/normalize.ts CHANGED
@@ -1,158 +1,9 @@
1
- import { demoPackageSchema, type DemoPackage, type TeamKey } from "@cs2dak/contract";
1
+ import { demoPackageSchema, type DemoPackage } from "@cs2dak/contract";
2
2
 
3
3
  export function parsePackageJson(text: string): unknown {
4
4
  return JSON.parse(text.replace(/\bNaN\b/g, "null"));
5
5
  }
6
6
 
7
7
  export function normalizeDemoPackage(input: unknown): DemoPackage {
8
- const raw = input as Record<string, unknown>;
9
- const manifest = raw.manifest as Record<string, unknown> | undefined;
10
-
11
- if (manifest?.schemaVersion === "cs2-demo-format/1.0") {
12
- return demoPackageSchema.parse(normalizeV1Package(raw));
13
- }
14
-
15
8
  return demoPackageSchema.parse(input);
16
9
  }
17
-
18
- function normalizeV1Package(raw: Record<string, unknown>): Record<string, unknown> {
19
- const match = raw.match as Record<string, unknown>;
20
- const rounds = asRecords(raw.rounds).filter((round) => numberValue(round.roundNumber) > 0);
21
- const roundByNumber = new Map(rounds.map((round) => [numberValue(round.roundNumber), round]));
22
-
23
- return {
24
- manifest: {
25
- ...(raw.manifest as Record<string, unknown>),
26
- schemaVersion: "cs2-demo-format/2.0"
27
- },
28
- match: {
29
- ...match,
30
- durationSeconds: numberValue(match.durationSeconds) > 0 ? match.durationSeconds : undefined
31
- },
32
- players: raw.players,
33
- rounds: rounds.map((round) => normalizeV1Round(round)),
34
- playerEconomies: asRecords(raw.playerEconomies)
35
- .filter((row) => numberValue(row.roundNumber) > 0)
36
- .map((row) => ({ ...row, type: normalizeEconomyType(row.type) })),
37
- playerStats: raw.playerStats ?? [],
38
- kills: asRecords(raw.kills)
39
- .filter((kill) => numberValue(kill.roundNumber) > 0)
40
- .map((kill) => normalizeV1Kill(kill, roundByNumber)),
41
- damages: asRecords(raw.damages)
42
- .filter((row) => numberValue(row.roundNumber) > 0)
43
- .map((row) => normalizeV1Damage(row, roundByNumber)),
44
- blinds: asRecords(raw.blinds).filter((row) => numberValue(row.roundNumber) > 0),
45
- grenades: asRecords(raw.grenades)
46
- .filter((row) => numberValue(row.roundNumber) > 0)
47
- .map((row) => normalizeV1Grenade(row, roundByNumber)),
48
- clutches: asRecords(raw.clutches).filter((row) => numberValue(row.roundNumber) > 0)
49
- };
50
- }
51
-
52
- function asRecords(value: unknown): Record<string, unknown>[] {
53
- return Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object" && !Array.isArray(row)) : [];
54
- }
55
-
56
- function normalizeV1Round(round: Record<string, unknown>): Record<string, unknown> {
57
- return {
58
- ...round,
59
- startTick: positiveInt(round.startTick),
60
- freezeEndTick: positiveInt(round.freezeEndTick),
61
- endTick: positiveInt(round.endTick),
62
- teamASide: normalizeSide(round.teamASide) ?? "t",
63
- teamBSide: normalizeSide(round.teamBSide) ?? "ct",
64
- teamAEconomy: normalizeEconomyType(round.teamAEconomy),
65
- teamBEconomy: normalizeEconomyType(round.teamBEconomy),
66
- winnerSide: normalizeSide(round.winnerSide) ?? sideForTeam(round.winnerTeamKey, round) ?? "t"
67
- };
68
- }
69
-
70
- function normalizeV1Kill(kill: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
71
- const round = rounds.get(numberValue(kill.roundNumber));
72
- return {
73
- ...kill,
74
- tick: positiveInt(kill.tick),
75
- killerTeamKey: normalizeTeamKey(kill.killerTeamKey),
76
- victimTeamKey: normalizeTeamKey(kill.victimTeamKey) ?? "teamA",
77
- killerSide: normalizeSide(kill.killerSide) ?? sideForTeam(kill.killerTeamKey, round),
78
- victimSide: normalizeSide(kill.victimSide) ?? sideForTeam(kill.victimTeamKey, round) ?? "t",
79
- killerPosition: sanitizeNullablePosition(kill.killerPosition),
80
- victimPosition: sanitizePosition(kill.victimPosition)
81
- };
82
- }
83
-
84
- function normalizeV1Damage(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
85
- const round = rounds.get(numberValue(row.roundNumber));
86
- return {
87
- ...row,
88
- tick: positiveInt(row.tick),
89
- weapon: typeof row.weapon === "string" && row.weapon.length > 0 ? row.weapon : "unknown",
90
- attackerTeamKey: normalizeTeamKey(row.attackerTeamKey),
91
- victimTeamKey: normalizeTeamKey(row.victimTeamKey) ?? "teamA",
92
- attackerSide: normalizeSide(row.attackerSide) ?? sideForTeam(row.attackerTeamKey, round),
93
- victimSide: normalizeSide(row.victimSide) ?? sideForTeam(row.victimTeamKey, round) ?? "t",
94
- attackerPosition: sanitizeNullablePosition(row.attackerPosition),
95
- victimPosition: sanitizeNullablePosition(row.victimPosition) ?? undefined
96
- };
97
- }
98
-
99
- function normalizeV1Grenade(row: Record<string, unknown>, rounds: Map<number, Record<string, unknown>>): Record<string, unknown> {
100
- const round = rounds.get(numberValue(row.roundNumber));
101
- const teamKey = normalizeTeamKey(row.throwerTeamKey ?? row.teamKey);
102
- return {
103
- roundNumber: row.roundNumber,
104
- tick: positiveInt(row.effectTick ?? row.throwTick ?? row.tick),
105
- steamId64: row.throwerSteamId64 ?? row.steamId64 ?? null,
106
- teamKey,
107
- side: normalizeSide(row.throwerSide ?? row.side) ?? sideForTeam(teamKey, round),
108
- grenadeType: typeof row.grenade === "string" && row.grenade.length > 0 ? row.grenade : row.grenadeType ?? "unknown",
109
- eventType: row.eventType ?? "effect",
110
- position: sanitizeNullablePosition(row.effectPosition ?? row.throwPosition ?? row.position)
111
- };
112
- }
113
-
114
- function sideForTeam(teamKey: unknown, round: Record<string, unknown> | undefined): "t" | "ct" | null {
115
- const team = normalizeTeamKey(teamKey);
116
- if (!team || !round) {
117
- return null;
118
- }
119
- return normalizeSide(team === "teamA" ? round.teamASide : round.teamBSide);
120
- }
121
-
122
- function normalizeTeamKey(value: unknown): TeamKey | null {
123
- return value === "teamA" || value === "teamB" ? value : null;
124
- }
125
-
126
- function normalizeSide(value: unknown): "t" | "ct" | null {
127
- return value === "t" || value === "ct" ? value : null;
128
- }
129
-
130
- function normalizeEconomyType(value: unknown): "pistol" | "eco" | "semi" | "force" | "full" | "conversion" {
131
- return value === "pistol" || value === "eco" || value === "semi" || value === "force" || value === "full" || value === "conversion" ? value : "full";
132
- }
133
-
134
- function numberValue(value: unknown): number {
135
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
136
- }
137
-
138
- function positiveInt(value: unknown): number {
139
- return Math.max(1, Math.trunc(numberValue(value)));
140
- }
141
-
142
- function sanitizeNullablePosition(value: unknown): { x: number; y: number; z: number } | null {
143
- if (!value || typeof value !== "object" || Array.isArray(value)) {
144
- return null;
145
- }
146
- const point = value as Record<string, unknown>;
147
- const x = numberValue(point.x);
148
- const y = numberValue(point.y);
149
- const z = numberValue(point.z);
150
- if (x === 0 && y === 0 && z === 0) {
151
- return null;
152
- }
153
- return { x, y, z };
154
- }
155
-
156
- function sanitizePosition(value: unknown): { x: number; y: number; z: number } {
157
- return sanitizeNullablePosition(value) ?? { x: 0, y: 0, z: 0 };
158
- }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DemoPackage } from "@cs2dak/contract";
3
+ import { buildTeamSideWinRates } from "./side-win-rate";
4
+
5
+ describe("buildTeamSideWinRates", () => {
6
+ it("aggregates T/CT win rates for both teams from canonical rounds", () => {
7
+ const pkg = {
8
+ rounds: [
9
+ { teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamA" },
10
+ { teamASide: "ct", teamBSide: "t", winnerTeamKey: "teamB" },
11
+ { teamASide: "t", teamBSide: "ct", winnerTeamKey: "teamA" }
12
+ ]
13
+ } as DemoPackage;
14
+
15
+ expect(buildTeamSideWinRates(pkg)).toEqual({
16
+ teamA: {
17
+ ct: { played: 2, won: 1, winRate: 0.5 },
18
+ t: { played: 1, won: 1, winRate: 1 }
19
+ },
20
+ teamB: {
21
+ t: { played: 2, won: 1, winRate: 0.5 },
22
+ ct: { played: 1, won: 0, winRate: 0 }
23
+ }
24
+ });
25
+ });
26
+
27
+ it("returns empty team summaries when rounds are unavailable", () => {
28
+ expect(buildTeamSideWinRates({ rounds: [] } as unknown as DemoPackage)).toEqual({
29
+ teamA: {},
30
+ teamB: {}
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,49 @@
1
+ import type { DemoPackage, Side, TeamKey } from "@cs2dak/contract";
2
+
3
+ export interface SideWinRateStats {
4
+ played: number;
5
+ won: number;
6
+ winRate: number;
7
+ }
8
+
9
+ export type TeamSideWinRates = Record<TeamKey, Partial<Record<Side, SideWinRateStats>>>;
10
+
11
+ interface SideRoundResult {
12
+ side: Side;
13
+ won: boolean;
14
+ }
15
+
16
+ function aggregateSideWinRates(rows: SideRoundResult[]): Partial<Record<Side, SideWinRateStats>> {
17
+ const groups = new Map<Side, { played: number; won: number }>();
18
+ for (const row of rows) {
19
+ const group = groups.get(row.side) ?? { played: 0, won: 0 };
20
+ group.played += 1;
21
+ if (row.won) group.won += 1;
22
+ groups.set(row.side, group);
23
+ }
24
+
25
+ return Object.fromEntries(
26
+ [...groups.entries()].map(([side, stats]) => [
27
+ side,
28
+ { ...stats, winRate: stats.played > 0 ? stats.won / stats.played : 0 }
29
+ ])
30
+ );
31
+ }
32
+
33
+ /**
34
+ * 按 T/CT side 汇总一场比赛中两队的回合胜率。
35
+ *
36
+ * 取代产品从数据库行重新拼装的 half-side 聚合;产品只负责选择 DemoPackage。
37
+ */
38
+ export function buildTeamSideWinRates(pkg: DemoPackage): TeamSideWinRates {
39
+ return {
40
+ teamA: aggregateSideWinRates(pkg.rounds.map((round) => ({
41
+ side: round.teamASide,
42
+ won: round.winnerTeamKey === "teamA"
43
+ }))),
44
+ teamB: aggregateSideWinRates(pkg.rounds.map((round) => ({
45
+ side: round.teamBSide,
46
+ won: round.winnerTeamKey === "teamB"
47
+ })))
48
+ };
49
+ }
package/src/signals.ts CHANGED
@@ -48,7 +48,7 @@ export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
48
48
  return {
49
49
  steamId64: player.steamId64,
50
50
  rounds,
51
- sourceVersion: "cs2-demo-analysis-kit/0.3",
51
+ sourceVersion: "cs2-demo-analysis-kit/1.0",
52
52
  combat: {
53
53
  kills: stats?.kills ?? playerKills.length,
54
54
  deaths: stats?.deaths ?? playerDeaths.length,
@@ -0,0 +1,55 @@
1
+ import {
2
+ playerWeaponHighlightFactsSchema,
3
+ type DemoPackage,
4
+ type PlayerWeaponHighlightFacts
5
+ } from "@cs2dak/contract";
6
+ import { normalizeDemoPackage } from "./normalize.js";
7
+ import { killWeaponName } from "./utils.js";
8
+
9
+ export function derivePlayerWeaponHighlights(input: unknown): PlayerWeaponHighlightFacts[] {
10
+ return buildPlayerWeaponHighlights(normalizeDemoPackage(input));
11
+ }
12
+
13
+ export function buildPlayerWeaponHighlights(pkg: DemoPackage): PlayerWeaponHighlightFacts[] {
14
+ const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
15
+
16
+ return pkg.players.map((player) => {
17
+ const stats = statsBySteamId.get(player.steamId64);
18
+ const kills = pkg.kills.filter((kill) => kill.killerSteamId64 === player.steamId64);
19
+ const weaponCounts = new Map<string, PlayerWeaponHighlightFacts["weapons"][number]>();
20
+ for (const kill of kills) {
21
+ const weapon = killWeaponName(kill);
22
+ const row = weaponCounts.get(weapon) ?? {
23
+ weapon,
24
+ kills: 0,
25
+ headshotKills: 0,
26
+ tradeKills: 0,
27
+ noScopeKills: 0,
28
+ throughSmokeKills: 0,
29
+ wallbangKills: 0,
30
+ penetratedObjects: 0
31
+ };
32
+ row.kills += 1;
33
+ if (kill.headshot) row.headshotKills += 1;
34
+ if (kill.tradeKill) row.tradeKills += 1;
35
+ if (kill.noScope) row.noScopeKills += 1;
36
+ if (kill.throughSmoke) row.throughSmokeKills += 1;
37
+ if ((kill.penetratedObjects ?? 0) > 0) row.wallbangKills += 1;
38
+ row.penetratedObjects += kill.penetratedObjects ?? 0;
39
+ weaponCounts.set(weapon, row);
40
+ }
41
+
42
+ return playerWeaponHighlightFactsSchema.parse({
43
+ steamId64: player.steamId64,
44
+ totalKills: kills.length,
45
+ weapons: [...weaponCounts.values()]
46
+ .sort((a, b) => b.kills - a.kills || a.weapon.localeCompare(b.weapon)),
47
+ highlights: {
48
+ wallbangKills: stats?.wallbangKillCount ?? kills.filter((kill) => (kill.penetratedObjects ?? 0) > 0).length,
49
+ noScopeKills: stats?.noScopeKillCount ?? kills.filter((kill) => kill.noScope).length,
50
+ throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length,
51
+ collateralKills: stats?.collateralKillCount ?? null
52
+ }
53
+ });
54
+ });
55
+ }
@@ -1,32 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { displayWeaponName } from "./weapons";
3
-
4
- describe("displayWeaponName", () => {
5
- it("maps raw codes to canonical display names", () => {
6
- expect(displayWeaponName("ak47")).toBe("AK-47");
7
- expect(displayWeaponName("m4a1_silencer")).toBe("M4A1-S");
8
- expect(displayWeaponName("awp")).toBe("AWP");
9
- expect(displayWeaponName("deagle")).toBe("Desert Eagle");
10
- });
11
-
12
- it("strips the weapon_ prefix and is case-insensitive", () => {
13
- expect(displayWeaponName("weapon_ak47")).toBe("AK-47");
14
- expect(displayWeaponName("WEAPON_AWP")).toBe("AWP");
15
- });
16
-
17
- it("collapses every knife/bayonet variant to a single label", () => {
18
- expect(displayWeaponName("knife")).toBe("knife");
19
- expect(displayWeaponName("knife_karambit")).toBe("knife");
20
- expect(displayWeaponName("weapon_bayonet")).toBe("knife");
21
- });
22
-
23
- it("falls back to the normalized code instead of an unknown placeholder", () => {
24
- expect(displayWeaponName("weapon_some_future_gun")).toBe("some_future_gun");
25
- });
26
-
27
- it("never returns a purely numeric string for a named weapon", () => {
28
- for (const raw of ["ak47", "m4a1_silencer", "knife_m9_bayonet", "future_gun"]) {
29
- expect(/^\d+$/.test(displayWeaponName(raw))).toBe(false);
30
- }
31
- });
32
- });