@rine-kit/pubg-intelligence 0.0.0-stage → 0.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/analytics.d.ts +176 -0
  3. package/dist/src/analytics.js +431 -0
  4. package/dist/src/analytics.js.map +1 -0
  5. package/dist/src/assistant.d.ts +87 -0
  6. package/dist/src/assistant.js +310 -0
  7. package/dist/src/assistant.js.map +1 -0
  8. package/dist/src/battle.d.ts +280 -0
  9. package/dist/src/battle.js +1249 -0
  10. package/dist/src/battle.js.map +1 -0
  11. package/dist/src/combat-long.d.ts +42 -0
  12. package/dist/src/combat-long.js +146 -0
  13. package/dist/src/combat-long.js.map +1 -0
  14. package/dist/src/combat-quality.d.ts +101 -0
  15. package/dist/src/combat-quality.js +253 -0
  16. package/dist/src/combat-quality.js.map +1 -0
  17. package/dist/src/evaluation-fixtures.d.ts +13 -0
  18. package/dist/src/evaluation-fixtures.js +144 -0
  19. package/dist/src/evaluation-fixtures.js.map +1 -0
  20. package/dist/src/evaluation-replay.d.ts +5 -0
  21. package/dist/src/evaluation-replay.js +107 -0
  22. package/dist/src/evaluation-replay.js.map +1 -0
  23. package/dist/src/evaluation.d.ts +140 -0
  24. package/dist/src/evaluation.js +180 -0
  25. package/dist/src/evaluation.js.map +1 -0
  26. package/dist/src/experiment-store.d.ts +56 -0
  27. package/dist/src/experiment-store.js +159 -0
  28. package/dist/src/experiment-store.js.map +1 -0
  29. package/dist/src/field.d.ts +103 -0
  30. package/dist/src/field.js +298 -0
  31. package/dist/src/field.js.map +1 -0
  32. package/dist/src/fights.d.ts +89 -0
  33. package/dist/src/fights.js +238 -0
  34. package/dist/src/fights.js.map +1 -0
  35. package/dist/src/highlights.d.ts +44 -0
  36. package/dist/src/highlights.js +153 -0
  37. package/dist/src/highlights.js.map +1 -0
  38. package/dist/src/index.d.ts +24 -0
  39. package/dist/src/index.js +25 -0
  40. package/dist/src/index.js.map +1 -0
  41. package/dist/src/insights.d.ts +44 -0
  42. package/dist/src/insights.js +343 -0
  43. package/dist/src/insights.js.map +1 -0
  44. package/dist/src/intent.d.ts +40 -0
  45. package/dist/src/intent.js +119 -0
  46. package/dist/src/intent.js.map +1 -0
  47. package/dist/src/landing.d.ts +74 -0
  48. package/dist/src/landing.js +195 -0
  49. package/dist/src/landing.js.map +1 -0
  50. package/dist/src/lobby.d.ts +48 -0
  51. package/dist/src/lobby.js +209 -0
  52. package/dist/src/lobby.js.map +1 -0
  53. package/dist/src/match-insight.d.ts +165 -0
  54. package/dist/src/match-insight.js +542 -0
  55. package/dist/src/match-insight.js.map +1 -0
  56. package/dist/src/match-relative.d.ts +73 -0
  57. package/dist/src/match-relative.js +145 -0
  58. package/dist/src/match-relative.js.map +1 -0
  59. package/dist/src/match-story.d.ts +155 -0
  60. package/dist/src/match-story.js +636 -0
  61. package/dist/src/match-story.js.map +1 -0
  62. package/dist/src/profiles.d.ts +41 -0
  63. package/dist/src/profiles.js +136 -0
  64. package/dist/src/profiles.js.map +1 -0
  65. package/dist/src/raw-telemetry-archive.d.ts +29 -0
  66. package/dist/src/raw-telemetry-archive.js +340 -0
  67. package/dist/src/raw-telemetry-archive.js.map +1 -0
  68. package/dist/src/raw-telemetry-backfill.d.ts +28 -0
  69. package/dist/src/raw-telemetry-backfill.js +144 -0
  70. package/dist/src/raw-telemetry-backfill.js.map +1 -0
  71. package/dist/src/research.d.ts +89 -0
  72. package/dist/src/research.js +376 -0
  73. package/dist/src/research.js.map +1 -0
  74. package/dist/src/rhythm.d.ts +104 -0
  75. package/dist/src/rhythm.js +260 -0
  76. package/dist/src/rhythm.js.map +1 -0
  77. package/dist/src/streaks.d.ts +117 -0
  78. package/dist/src/streaks.js +367 -0
  79. package/dist/src/streaks.js.map +1 -0
  80. package/dist/src/style.d.ts +304 -0
  81. package/dist/src/style.js +1461 -0
  82. package/dist/src/style.js.map +1 -0
  83. package/dist/src/tactics.d.ts +88 -0
  84. package/dist/src/tactics.js +1221 -0
  85. package/dist/src/tactics.js.map +1 -0
  86. package/package.json +49 -3
  87. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChristmasFox / Arthur Xu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,176 @@
1
+ import type { NormalizedMatch } from '@rine-kit/pubg-domain';
2
+ export declare const ANALYTICS_SCHEMA_VERSION: 1;
3
+ export declare const ANALYTICS_RULE_VERSION: "performance-analytics-v1";
4
+ export declare const ANALYTICS_RECENT_WINDOW = 5;
5
+ export declare const ANALYTICS_MIN_SAMPLES = 8;
6
+ export declare const ANALYTICS_FORM_Z = 1.5;
7
+ export declare const ANALYTICS_EWMA_ALPHA = 0.3;
8
+ export declare const ANALYTICS_WEAPON_PRIOR_SHOTS = 30;
9
+ export declare const ANALYTICS_WEAPON_MIN_SHOTS = 20;
10
+ export type AnalyticsMetricKey = 'damage' | 'kills' | 'knocks' | 'revives' | 'survivalTime';
11
+ export type AnalyticsForm = 'hot' | 'steady' | 'cold' | 'insufficient';
12
+ export type AnalyticsTrend = 'up' | 'down' | 'flat' | 'insufficient';
13
+ export interface AnalyticsMetricSummary {
14
+ key: AnalyticsMetricKey;
15
+ label: string;
16
+ samples: number;
17
+ mean: number;
18
+ median: number;
19
+ /** Robust scale: 1.4826 * MAD, floored so a flat history cannot inflate z-scores. */
20
+ scale: number;
21
+ latest: number | null;
22
+ ewma: number | null;
23
+ recentMean: number | null;
24
+ baselineMedian: number | null;
25
+ /** Standardised distance of the recent window from the earlier baseline; null when history is short. */
26
+ formZ: number | null;
27
+ form: AnalyticsForm;
28
+ /** Theil-Sen slope in metric units per match. */
29
+ slope: number | null;
30
+ trend: AnalyticsTrend;
31
+ /** Fraction of all samples at or below the latest value, in 0..1. */
32
+ latestPercentile: number | null;
33
+ }
34
+ export interface RateEstimate {
35
+ successes: number;
36
+ trials: number;
37
+ rate: number | null;
38
+ low: number | null;
39
+ high: number | null;
40
+ }
41
+ export interface PlayerAnalytics {
42
+ id: string;
43
+ name: string;
44
+ matches: number;
45
+ metrics: AnalyticsMetricSummary[];
46
+ /** Share of the squad's total damage across matches the player appeared in. */
47
+ damageShare: number | null;
48
+ killShare: number | null;
49
+ }
50
+ export interface SquadAnalytics {
51
+ schemaVersion: typeof ANALYTICS_SCHEMA_VERSION;
52
+ ruleVersion: typeof ANALYTICS_RULE_VERSION;
53
+ matches: number;
54
+ rankedMatches: number;
55
+ win: RateEstimate;
56
+ top10: RateEstimate;
57
+ medianPlacement: number | null;
58
+ placementTrend: AnalyticsTrend;
59
+ placementSlope: number | null;
60
+ players: PlayerAnalytics[];
61
+ notes: string[];
62
+ }
63
+ export interface WeaponSample {
64
+ playerId: string;
65
+ weapon: string;
66
+ shots: number;
67
+ hits: number;
68
+ damage: number;
69
+ knocks: number;
70
+ kills: number;
71
+ }
72
+ export interface WeaponEfficiency {
73
+ weapon: string;
74
+ shots: number;
75
+ hits: number;
76
+ damage: number;
77
+ knocks: number;
78
+ kills: number;
79
+ rawHitRate: number | null;
80
+ /** Hit rate shrunk toward the pooled rate; small samples stay near the baseline. */
81
+ shrunkHitRate: number | null;
82
+ damagePerHit: number | null;
83
+ damagePerShot: number | null;
84
+ /** Shrunk hit rate minus the pooled rate. */
85
+ edge: number | null;
86
+ reliable: boolean;
87
+ }
88
+ export declare function median(values: readonly number[]): number | null;
89
+ /** Theil-Sen estimator over evenly spaced samples; robust to a single outlier match. */
90
+ export declare function theilSenSlope(values: readonly number[]): number | null;
91
+ /** Wilson score interval; stays inside 0..1 and is usable for the small samples a squad produces. */
92
+ export declare function wilsonInterval(successes: number, trials: number, z?: number): RateEstimate;
93
+ export declare function summarizeSeries(key: AnalyticsMetricKey, label: string, series: readonly number[], floor: number): AnalyticsMetricSummary;
94
+ export interface BuildSquadAnalyticsInput {
95
+ matches: readonly NormalizedMatch[];
96
+ memberIds: readonly string[];
97
+ }
98
+ export declare function buildSquadAnalytics(input: BuildSquadAnalyticsInput): SquadAnalytics;
99
+ /**
100
+ * Telemetry names the same gun differently across event types (`Item_Weapon_AUG_C` on attacks,
101
+ * `WeapAUG_C` on damage, `ZU_Item_Weapon_AUG` for event variants), so shots and hits only meet
102
+ * after the prefixes are stripped. Returns null for melee and throwables.
103
+ */
104
+ export declare function normalizeWeaponName(weapon: string): string | null;
105
+ /** Pools samples by weapon and shrinks each hit rate toward the all-weapon rate. */
106
+ export declare function buildWeaponEfficiency(samples: readonly WeaponSample[]): WeaponEfficiency[];
107
+ export declare function mapDisplayName(mapName: string): string;
108
+ export declare function mapSizeKm(mapName: string): number | null;
109
+ export declare const MAP_INDEX_MIN_MATCHES = 5;
110
+ export interface MapAnalytics {
111
+ mapName: string;
112
+ label: string;
113
+ matches: number;
114
+ rankedMatches: number;
115
+ win: RateEstimate;
116
+ top10: RateEstimate;
117
+ medianPlacement: number | null;
118
+ averageDamage: number;
119
+ averageKills: number;
120
+ averageSurvivalTime: number;
121
+ /** Per-player average damage relative to the same players' average on every map; 1 means no map effect. */
122
+ damageIndex: number | null;
123
+ }
124
+ export declare function buildMapAnalytics(input: BuildSquadAnalyticsInput): MapAnalytics[];
125
+ /** Groups raw `gameMode` values (`squad`, `squad-fpp`, ...) so rates are only compared within one team size. */
126
+ export type ModeGroup = 'squad' | 'duo' | 'solo' | 'other';
127
+ export declare const MODE_GROUPS: readonly ModeGroup[];
128
+ export declare function modeGroupOf(gameMode: string): ModeGroup;
129
+ export declare const SERIES_MAX_POINTS = 150;
130
+ export interface MatchSeriesPoint {
131
+ matchId: string;
132
+ timestamp: number;
133
+ mapLabel: string;
134
+ placement: number | null;
135
+ /** Average damage per tracked player in the match. */
136
+ damage: number;
137
+ kills: number;
138
+ }
139
+ export declare function buildMatchSeries(input: BuildSquadAnalyticsInput): MatchSeriesPoint[];
140
+ export interface DifferenceEstimate {
141
+ delta: number | null;
142
+ low: number | null;
143
+ high: number | null;
144
+ /** True when the 95% interval of the difference excludes zero. */
145
+ significant: boolean;
146
+ }
147
+ /** Newcombe hybrid-score interval for the difference of two proportions (a minus b). */
148
+ export declare function newcombeDifference(a: RateEstimate, b: RateEstimate): DifferenceEstimate;
149
+ export declare const LINEUP_MIN_MATCHES = 5;
150
+ export interface LineupStats {
151
+ names: string[];
152
+ size: number;
153
+ matches: number;
154
+ rankedMatches: number;
155
+ top10: RateEstimate;
156
+ win: RateEstimate;
157
+ medianPlacement: number | null;
158
+ averageDamage: number;
159
+ }
160
+ export interface MemberImpact {
161
+ id: string;
162
+ name: string;
163
+ withMatches: number;
164
+ withoutMatches: number;
165
+ withTop10: RateEstimate;
166
+ withoutTop10: RateEstimate;
167
+ difference: DifferenceEstimate;
168
+ withDamage: number | null;
169
+ withoutDamage: number | null;
170
+ }
171
+ export interface LineupAnalytics {
172
+ lineups: LineupStats[];
173
+ members: MemberImpact[];
174
+ notes: string[];
175
+ }
176
+ export declare function buildLineupAnalytics(input: BuildSquadAnalyticsInput): LineupAnalytics;
@@ -0,0 +1,431 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const ANALYTICS_SCHEMA_VERSION = 1;
3
+ export const ANALYTICS_RULE_VERSION = 'performance-analytics-v1';
4
+ export const ANALYTICS_RECENT_WINDOW = 5;
5
+ export const ANALYTICS_MIN_SAMPLES = 8;
6
+ export const ANALYTICS_FORM_Z = 1.5;
7
+ export const ANALYTICS_EWMA_ALPHA = 0.3;
8
+ export const ANALYTICS_WEAPON_PRIOR_SHOTS = 30;
9
+ export const ANALYTICS_WEAPON_MIN_SHOTS = 20;
10
+ const METRICS = [
11
+ { key: 'damage', label: '伤害', pick: (player) => player.damage, floor: 20 },
12
+ { key: 'kills', label: '击杀', pick: (player) => player.kills, floor: 0.5 },
13
+ { key: 'knocks', label: '倒地', pick: (player) => player.dbnos, floor: 0.5 },
14
+ { key: 'revives', label: '救援', pick: (player) => player.revives, floor: 0.3 },
15
+ { key: 'survivalTime', label: '生存时长', pick: (player) => player.survivalTime, floor: 60 },
16
+ ];
17
+ export function median(values) {
18
+ if (values.length === 0)
19
+ return null;
20
+ const sorted = [...values].sort((left, right) => left - right);
21
+ const mid = Math.floor(sorted.length / 2);
22
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
23
+ }
24
+ function mean(values) {
25
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
26
+ }
27
+ /** Theil-Sen estimator over evenly spaced samples; robust to a single outlier match. */
28
+ export function theilSenSlope(values) {
29
+ if (values.length < 3)
30
+ return null;
31
+ const slopes = [];
32
+ for (let left = 0; left < values.length - 1; left += 1) {
33
+ for (let right = left + 1; right < values.length; right += 1)
34
+ slopes.push((values[right] - values[left]) / (right - left));
35
+ }
36
+ return median(slopes);
37
+ }
38
+ /** Wilson score interval; stays inside 0..1 and is usable for the small samples a squad produces. */
39
+ export function wilsonInterval(successes, trials, z = 1.96) {
40
+ if (!Number.isFinite(successes) ||
41
+ !Number.isFinite(trials) ||
42
+ trials <= 0 ||
43
+ successes < 0 ||
44
+ successes > trials) {
45
+ return { successes: 0, trials: 0, rate: null, low: null, high: null };
46
+ }
47
+ const p = successes / trials;
48
+ const denominator = 1 + (z * z) / trials;
49
+ const centre = (p + (z * z) / (2 * trials)) / denominator;
50
+ const margin = (z * Math.sqrt((p * (1 - p)) / trials + (z * z) / (4 * trials * trials))) / denominator;
51
+ return {
52
+ successes,
53
+ trials,
54
+ rate: p,
55
+ low: Math.max(0, centre - margin),
56
+ high: Math.min(1, centre + margin),
57
+ };
58
+ }
59
+ function robustScale(values, centre, floor) {
60
+ const mad = median(values.map((value) => Math.abs(value - centre))) ?? 0;
61
+ return Math.max(1.4826 * mad, floor, Math.abs(centre) * 0.1);
62
+ }
63
+ export function summarizeSeries(key, label, series, floor) {
64
+ const samples = series.length;
65
+ const centre = median(series) ?? 0;
66
+ const scale = robustScale(series, centre, floor);
67
+ const latest = samples ? series[samples - 1] : null;
68
+ let ewma = null;
69
+ for (const value of series)
70
+ ewma = ewma === null ? value : ANALYTICS_EWMA_ALPHA * value + (1 - ANALYTICS_EWMA_ALPHA) * ewma;
71
+ let recentMean = null;
72
+ let baselineMedian = null;
73
+ let formZ = null;
74
+ let form = 'insufficient';
75
+ if (samples >= ANALYTICS_MIN_SAMPLES) {
76
+ const recent = series.slice(-ANALYTICS_RECENT_WINDOW);
77
+ const baseline = series.slice(0, -ANALYTICS_RECENT_WINDOW);
78
+ recentMean = mean(recent);
79
+ baselineMedian = median(baseline);
80
+ const baselineScale = robustScale(baseline, baselineMedian ?? 0, floor);
81
+ formZ = (recentMean - (baselineMedian ?? 0)) / (baselineScale / Math.sqrt(recent.length));
82
+ form = formZ >= ANALYTICS_FORM_Z ? 'hot' : formZ <= -ANALYTICS_FORM_Z ? 'cold' : 'steady';
83
+ }
84
+ const slope = samples >= ANALYTICS_MIN_SAMPLES ? theilSenSlope(series) : null;
85
+ const trend = trendOf(slope, samples, scale);
86
+ const latestPercentile = latest === null ? null : series.filter((value) => value <= latest).length / samples;
87
+ return {
88
+ key,
89
+ label,
90
+ samples,
91
+ mean: mean(series),
92
+ median: centre,
93
+ scale,
94
+ latest,
95
+ ewma,
96
+ recentMean,
97
+ baselineMedian,
98
+ formZ,
99
+ form,
100
+ slope,
101
+ trend,
102
+ latestPercentile,
103
+ };
104
+ }
105
+ function trendOf(slope, samples, scale) {
106
+ if (slope === null)
107
+ return 'insufficient';
108
+ const drift = slope * (samples - 1);
109
+ if (Math.abs(drift) < scale * 0.5)
110
+ return 'flat';
111
+ return drift > 0 ? 'up' : 'down';
112
+ }
113
+ function placementOf(players) {
114
+ const ranks = new Set(players
115
+ .map((player) => player.rank)
116
+ .filter((rank) => rank !== null && rank > 0));
117
+ return ranks.size === 1 ? [...ranks][0] : null;
118
+ }
119
+ export function buildSquadAnalytics(input) {
120
+ const members = new Set(input.memberIds);
121
+ const rows = [...input.matches]
122
+ .filter((match) => Number.isFinite(match.timestamp))
123
+ .sort((left, right) => left.timestamp - right.timestamp || left.matchId.localeCompare(right.matchId))
124
+ .flatMap((match) => {
125
+ const players = match.players.filter((player) => members.has(player.accountId));
126
+ return players.length ? [{ match, players, placement: placementOf(players) }] : [];
127
+ });
128
+ const ranked = rows.filter((row) => row.placement !== null);
129
+ const placements = ranked.map((row) => row.placement);
130
+ const placementSlope = theilSenSlope(placements.length >= ANALYTICS_MIN_SAMPLES ? placements : []);
131
+ const placementScale = robustScale(placements, median(placements) ?? 0, 1);
132
+ // A falling placement number is an improvement, so the trend direction is inverted for display.
133
+ const rawTrend = trendOf(placementSlope, placements.length, placementScale);
134
+ const placementTrend = rawTrend === 'up' ? 'down' : rawTrend === 'down' ? 'up' : rawTrend;
135
+ const squadDamage = rows.reduce((sum, row) => sum + row.players.reduce((value, player) => value + player.damage, 0), 0);
136
+ const squadKills = rows.reduce((sum, row) => sum + row.players.reduce((value, player) => value + player.kills, 0), 0);
137
+ const byPlayer = new Map();
138
+ for (const row of rows) {
139
+ for (const player of row.players)
140
+ byPlayer.set(player.accountId, [...(byPlayer.get(player.accountId) ?? []), { player, row }]);
141
+ }
142
+ const players = [...byPlayer.entries()]
143
+ .map(([accountId, entries]) => {
144
+ const latest = entries.at(-1).player;
145
+ const damage = entries.reduce((sum, entry) => sum + entry.player.damage, 0);
146
+ const kills = entries.reduce((sum, entry) => sum + entry.player.kills, 0);
147
+ return {
148
+ id: createHash('sha256').update(accountId).digest('hex').slice(0, 12),
149
+ name: latest.displayName || latest.playerName,
150
+ matches: entries.length,
151
+ metrics: METRICS.map((metric) => summarizeSeries(metric.key, metric.label, entries.map((entry) => metric.pick(entry.player)), metric.floor)),
152
+ damageShare: squadDamage > 0 ? damage / squadDamage : null,
153
+ killShare: squadKills > 0 ? kills / squadKills : null,
154
+ };
155
+ })
156
+ .sort((left, right) => (right.damageShare ?? 0) - (left.damageShare ?? 0) || left.name.localeCompare(right.name));
157
+ const notes = [];
158
+ if (rows.length < ANALYTICS_MIN_SAMPLES)
159
+ notes.push(`样本不足 ${ANALYTICS_MIN_SAMPLES} 场,状态与趋势暂不判断。`);
160
+ if (ranked.length < rows.length)
161
+ notes.push(`排名仅 ${ranked.length}/${rows.length} 场可用,胜率与名次统计只覆盖这些场次。`);
162
+ if (players.some((player) => player.matches < rows.length))
163
+ notes.push('部分队员没有出现在所有场次,占比按各自出场场次累计。');
164
+ return {
165
+ schemaVersion: ANALYTICS_SCHEMA_VERSION,
166
+ ruleVersion: ANALYTICS_RULE_VERSION,
167
+ matches: rows.length,
168
+ rankedMatches: ranked.length,
169
+ win: wilsonInterval(ranked.filter((row) => row.placement === 1).length, ranked.length),
170
+ top10: wilsonInterval(ranked.filter((row) => row.placement <= 10).length, ranked.length),
171
+ medianPlacement: median(placements),
172
+ placementTrend,
173
+ placementSlope,
174
+ players,
175
+ notes,
176
+ };
177
+ }
178
+ const NON_FIREARM = /^(pickaxe|grenade|smokebomb|molotov|flashbang|stungun|sticky|decoy|juju|m79|ziplinegun|flaregun)/iu;
179
+ /**
180
+ * Telemetry names the same gun differently across event types (`Item_Weapon_AUG_C` on attacks,
181
+ * `WeapAUG_C` on damage, `ZU_Item_Weapon_AUG` for event variants), so shots and hits only meet
182
+ * after the prefixes are stripped. Returns null for melee and throwables.
183
+ */
184
+ export function normalizeWeaponName(weapon) {
185
+ const name = weapon
186
+ .replace(/^ZU_Item_Weapon_/iu, '')
187
+ .replace(/^Item_Weapon_/iu, '')
188
+ .replace(/^Weap(?=[A-Z0-9]|vz)/u, '')
189
+ .replace(/_(C|Reward)$/iu, '')
190
+ .replace(/_Reward$/iu, '')
191
+ .replace(/_Projectile$/iu, '');
192
+ if (/^PanzerFaust/iu.test(name))
193
+ return 'PanzerFaust';
194
+ return !name || NON_FIREARM.test(name) ? null : name;
195
+ }
196
+ /** Pools samples by weapon and shrinks each hit rate toward the all-weapon rate. */
197
+ export function buildWeaponEfficiency(samples) {
198
+ const byWeapon = new Map();
199
+ for (const raw of samples) {
200
+ const weapon = normalizeWeaponName(raw.weapon);
201
+ if (!weapon)
202
+ continue;
203
+ const sample = { ...raw, weapon };
204
+ if (!(sample.shots >= 0) || !(sample.hits >= 0))
205
+ continue;
206
+ const current = byWeapon.get(sample.weapon) ?? {
207
+ playerId: '',
208
+ weapon: sample.weapon,
209
+ shots: 0,
210
+ hits: 0,
211
+ damage: 0,
212
+ knocks: 0,
213
+ kills: 0,
214
+ };
215
+ current.shots += sample.shots;
216
+ current.hits += sample.hits;
217
+ current.damage += sample.damage;
218
+ current.knocks += sample.knocks;
219
+ current.kills += sample.kills;
220
+ byWeapon.set(sample.weapon, current);
221
+ }
222
+ for (const [name, item] of byWeapon)
223
+ if (item.hits > item.shots)
224
+ byWeapon.delete(name);
225
+ const totalShots = [...byWeapon.values()].reduce((sum, item) => sum + item.shots, 0);
226
+ const totalHits = [...byWeapon.values()].reduce((sum, item) => sum + item.hits, 0);
227
+ const pooled = totalShots > 0 ? totalHits / totalShots : null;
228
+ return [...byWeapon.values()]
229
+ .map((item) => {
230
+ const shrunk = pooled === null
231
+ ? null
232
+ : (item.hits + ANALYTICS_WEAPON_PRIOR_SHOTS * pooled) /
233
+ (item.shots + ANALYTICS_WEAPON_PRIOR_SHOTS);
234
+ return {
235
+ weapon: item.weapon,
236
+ shots: item.shots,
237
+ hits: item.hits,
238
+ damage: item.damage,
239
+ knocks: item.knocks,
240
+ kills: item.kills,
241
+ rawHitRate: item.shots > 0 ? item.hits / item.shots : null,
242
+ shrunkHitRate: shrunk,
243
+ damagePerHit: item.hits > 0 ? item.damage / item.hits : null,
244
+ damagePerShot: item.shots > 0 ? item.damage / item.shots : null,
245
+ edge: shrunk !== null && pooled !== null ? shrunk - pooled : null,
246
+ reliable: item.shots >= ANALYTICS_WEAPON_MIN_SHOTS,
247
+ };
248
+ })
249
+ .sort((left, right) => right.shots - left.shots || left.weapon.localeCompare(right.weapon));
250
+ }
251
+ /** Display names and playable-area edge (km) for PUBG internal map identifiers; unknown maps keep their raw name. */
252
+ const MAP_INFO = {
253
+ Baltic_Main: { name: '艾伦格', sizeKm: 8 },
254
+ Desert_Main: { name: '米拉玛', sizeKm: 8 },
255
+ Tiger_Main: { name: '泰戈', sizeKm: 8 },
256
+ Neon_Main: { name: '荣都', sizeKm: 8 },
257
+ Kiki_Main: { name: '帝斯顿', sizeKm: 8 },
258
+ Savage_Main: { name: '萨诺', sizeKm: 4 },
259
+ DihorOtok_Main: { name: '维寒迪', sizeKm: 6 },
260
+ Chimera_Main: { name: '帕拉莫', sizeKm: 3 },
261
+ Summerland_Main: { name: '卡拉金', sizeKm: 2 },
262
+ Heaven_Main: { name: '哈文', sizeKm: 1 },
263
+ };
264
+ export function mapDisplayName(mapName) {
265
+ return MAP_INFO[mapName]?.name ?? (mapName.replace(/_Main$/u, '') || '未知地图');
266
+ }
267
+ export function mapSizeKm(mapName) {
268
+ return MAP_INFO[mapName]?.sizeKm ?? null;
269
+ }
270
+ export const MAP_INDEX_MIN_MATCHES = 5;
271
+ export function buildMapAnalytics(input) {
272
+ const members = new Set(input.memberIds);
273
+ const rows = input.matches.flatMap((match) => {
274
+ const players = match.players.filter((player) => members.has(player.accountId));
275
+ return players.length ? [{ match, players, placement: placementOf(players) }] : [];
276
+ });
277
+ const allPlayers = rows.flatMap((row) => row.players);
278
+ const overallDamage = allPlayers.length ? mean(allPlayers.map((player) => player.damage)) : 0;
279
+ const byMap = new Map();
280
+ for (const row of rows)
281
+ byMap.set(row.match.mapName || '未知地图', [
282
+ ...(byMap.get(row.match.mapName || '未知地图') ?? []),
283
+ row,
284
+ ]);
285
+ return [...byMap.entries()]
286
+ .map(([mapName, items]) => {
287
+ const players = items.flatMap((item) => item.players);
288
+ const ranked = items.filter((item) => item.placement !== null);
289
+ const averageDamage = mean(players.map((player) => player.damage));
290
+ return {
291
+ mapName,
292
+ label: mapDisplayName(mapName),
293
+ matches: items.length,
294
+ rankedMatches: ranked.length,
295
+ win: wilsonInterval(ranked.filter((item) => item.placement === 1).length, ranked.length),
296
+ top10: wilsonInterval(ranked.filter((item) => item.placement <= 10).length, ranked.length),
297
+ medianPlacement: median(ranked.map((item) => item.placement)),
298
+ averageDamage,
299
+ averageKills: mean(players.map((player) => player.kills)),
300
+ averageSurvivalTime: mean(players.map((player) => player.survivalTime)),
301
+ damageIndex: overallDamage > 0 && items.length >= MAP_INDEX_MIN_MATCHES
302
+ ? averageDamage / overallDamage
303
+ : null,
304
+ };
305
+ })
306
+ .sort((left, right) => right.matches - left.matches || left.mapName.localeCompare(right.mapName));
307
+ }
308
+ export const MODE_GROUPS = ['squad', 'duo', 'solo', 'other'];
309
+ export function modeGroupOf(gameMode) {
310
+ const mode = gameMode.toLowerCase();
311
+ if (mode === 'squad' || mode === 'squad-fpp')
312
+ return 'squad';
313
+ if (mode === 'duo' || mode === 'duo-fpp')
314
+ return 'duo';
315
+ if (mode === 'solo' || mode === 'solo-fpp')
316
+ return 'solo';
317
+ return 'other';
318
+ }
319
+ export const SERIES_MAX_POINTS = 150;
320
+ export function buildMatchSeries(input) {
321
+ const members = new Set(input.memberIds);
322
+ return [...input.matches]
323
+ .filter((match) => Number.isFinite(match.timestamp))
324
+ .sort((left, right) => left.timestamp - right.timestamp || left.matchId.localeCompare(right.matchId))
325
+ .flatMap((match) => {
326
+ const players = match.players.filter((player) => members.has(player.accountId));
327
+ if (!players.length)
328
+ return [];
329
+ return [
330
+ {
331
+ matchId: match.matchId,
332
+ timestamp: match.timestamp,
333
+ mapLabel: mapDisplayName(match.mapName || '未知地图'),
334
+ placement: placementOf(players),
335
+ damage: mean(players.map((player) => player.damage)),
336
+ kills: players.reduce((sum, player) => sum + player.kills, 0),
337
+ },
338
+ ];
339
+ })
340
+ .slice(-SERIES_MAX_POINTS);
341
+ }
342
+ /** Newcombe hybrid-score interval for the difference of two proportions (a minus b). */
343
+ export function newcombeDifference(a, b) {
344
+ if (a.rate === null ||
345
+ b.rate === null ||
346
+ a.low === null ||
347
+ a.high === null ||
348
+ b.low === null ||
349
+ b.high === null) {
350
+ return { delta: null, low: null, high: null, significant: false };
351
+ }
352
+ const delta = a.rate - b.rate;
353
+ const low = delta - Math.sqrt((a.rate - a.low) ** 2 + (b.high - b.rate) ** 2);
354
+ const high = delta + Math.sqrt((a.high - a.rate) ** 2 + (b.rate - b.low) ** 2);
355
+ return { delta, low, high, significant: low > 0 || high < 0 };
356
+ }
357
+ export const LINEUP_MIN_MATCHES = 5;
358
+ export function buildLineupAnalytics(input) {
359
+ const members = new Set(input.memberIds);
360
+ const rows = input.matches.flatMap((match) => {
361
+ const players = match.players.filter((player) => members.has(player.accountId));
362
+ return players.length ? [{ match, players, placement: placementOf(players) }] : [];
363
+ });
364
+ const nameOf = new Map();
365
+ for (const row of [...rows].sort((left, right) => left.match.timestamp - right.match.timestamp)) {
366
+ for (const player of row.players)
367
+ nameOf.set(player.accountId, player.displayName || player.playerName);
368
+ }
369
+ const summarize = (items) => {
370
+ const ranked = items.filter((item) => item.placement !== null);
371
+ return {
372
+ matches: items.length,
373
+ rankedMatches: ranked.length,
374
+ top10: wilsonInterval(ranked.filter((item) => item.placement <= 10).length, ranked.length),
375
+ win: wilsonInterval(ranked.filter((item) => item.placement === 1).length, ranked.length),
376
+ medianPlacement: median(ranked.map((item) => item.placement)),
377
+ averageDamage: items.length
378
+ ? mean(items.flatMap((item) => item.players.map((player) => player.damage)))
379
+ : 0,
380
+ };
381
+ };
382
+ const byLineup = new Map();
383
+ for (const row of rows) {
384
+ const key = row.players
385
+ .map((player) => player.accountId)
386
+ .sort()
387
+ .join('\u001f');
388
+ byLineup.set(key, [...(byLineup.get(key) ?? []), row]);
389
+ }
390
+ const lineups = [...byLineup.entries()]
391
+ .filter(([, items]) => items.length >= LINEUP_MIN_MATCHES)
392
+ .map(([key, items]) => {
393
+ const ids = key.split('\u001f');
394
+ return {
395
+ names: ids.map((id) => nameOf.get(id) ?? '未知').sort(),
396
+ size: ids.length,
397
+ ...summarize(items),
398
+ };
399
+ })
400
+ .sort((left, right) => right.matches - left.matches ||
401
+ right.size - left.size ||
402
+ left.names.join().localeCompare(right.names.join()));
403
+ const impacts = [...nameOf.entries()]
404
+ .map(([accountId, name]) => {
405
+ const withRows = rows.filter((row) => row.players.some((player) => player.accountId === accountId));
406
+ const withoutRows = rows.filter((row) => !row.players.some((player) => player.accountId === accountId));
407
+ const withStats = summarize(withRows);
408
+ const withoutStats = summarize(withoutRows);
409
+ return {
410
+ id: createHash('sha256').update(accountId).digest('hex').slice(0, 12),
411
+ name,
412
+ withMatches: withRows.length,
413
+ withoutMatches: withoutRows.length,
414
+ withTop10: withStats.top10,
415
+ withoutTop10: withoutStats.top10,
416
+ difference: newcombeDifference(withStats.top10, withoutStats.top10),
417
+ withDamage: withRows.length ? withStats.averageDamage : null,
418
+ withoutDamage: withoutRows.length ? withoutStats.averageDamage : null,
419
+ };
420
+ })
421
+ .sort((left, right) => right.withMatches - left.withMatches || left.name.localeCompare(right.name));
422
+ return {
423
+ lineups,
424
+ members: impacts,
425
+ notes: [
426
+ `阵容只列出至少 ${LINEUP_MIN_MATCHES} 场的组合。「在场 vs 缺席」比较的是前十率差,区间不跨 0 才标为显著。`,
427
+ '在场与缺席的对局由不同时间、地图和队友组成,这是相关性,不能直接说明某人的贡献。',
428
+ ],
429
+ };
430
+ }
431
+ //# sourceMappingURL=analytics.js.map