@rine-kit/pubg-intelligence 0.0.0-stage → 0.1.1

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
@@ -0,0 +1,1461 @@
1
+ import { parseTelemetryPayload } from '@rine-kit/pubg-domain';
2
+ import { normalizeWeaponName } from './analytics.js';
3
+ import { emptyRoleCounts } from './fights.js';
4
+ export const STYLE_SCHEMA_VERSION = 2;
5
+ export const STYLE_RULE_VERSION = 'play-style-v2';
6
+ export const STYLE_BOOTSTRAP_RESAMPLES = 300;
7
+ export const STYLE_BOOTSTRAP_SEED = 20261018;
8
+ /** Ratio to teammates beyond which a difference is worth naming; its inverse applies on the low side. */
9
+ export const STYLE_NOTABLE_RATIO = 1.15;
10
+ /** Share of bootstrap resamples that must agree on the direction for a "lean". */
11
+ export const STYLE_LEAN_CONFIDENCE = 0.85;
12
+ export const STYLE_MIN_FIGHTS = 40;
13
+ export const STYLE_MIN_DAMAGE = 1500;
14
+ export const STYLE_MIN_MINUTES = 60;
15
+ export const STYLE_MIN_SHOTS = 800;
16
+ /** A shot counts as part of a fight when someone on either side was hurt within this long before or after it. */
17
+ export const STYLE_ENGAGE_WINDOW_MS = 8000;
18
+ /** Fewest shots in a first-shot or follow-up group before its hit rate is measured. */
19
+ export const STYLE_MIN_SPLIT_SHOTS = 300;
20
+ /** A shot fired this soon after the previous one from the same weapon is a follow-up bullet of a burst or spray. */
21
+ export const STYLE_BURST_GAP_MS = 120;
22
+ /** Prior shots pulling a teammate class hit rate toward the teammates' overall rate in the weapon-mix adjustment. */
23
+ export const STYLE_CLASS_PRIOR_SHOTS = 200;
24
+ export const STYLE_MIN_KNOCKS = 25;
25
+ export const STYLE_MIN_SPACING_SAMPLES = 100;
26
+ export const STYLE_MIN_ZONE_SAMPLES = 150;
27
+ export const STYLE_MIN_PICKUP_MATCHES = 8;
28
+ export const STYLE_ARCHETYPE_STRONG = 0.45;
29
+ export const STYLE_ARCHETYPE_LEAN = 0.3;
30
+ export const STYLE_ARCHETYPE_SLIGHT = 0.1;
31
+ /** Weight of an axis that is close to teammates when scoring archetypes; lets a faint lean still point somewhere. */
32
+ export const STYLE_EVEN_FACTOR = 0.25;
33
+ export const STYLE_ISOLATED_METERS = 150;
34
+ export const STYLE_SPACING_BUCKET_SECONDS = 15;
35
+ export const STYLE_EARLY_LOOT_SECONDS = 240;
36
+ export const STYLE_LATE_PHASE = 5;
37
+ export const STYLE_EDGE_RATIO = 0.85;
38
+ export const STYLE_MAX_FOOT_STEP_METERS = 400;
39
+ export const STYLE_MAX_FOOT_GAP_SECONDS = 30;
40
+ /** Upper edges (metres) of the distance bins used for a damage-weighted median engagement distance. */
41
+ export const STYLE_DISTANCE_EDGES = [10, 25, 50, 75, 100, 150, 200, 300];
42
+ /** Upper edges (metres) of the nearest-teammate distance bins. */
43
+ export const STYLE_SPACING_EDGES = [20, 50, 100, 150, 250];
44
+ const DISTANCE_BIN_COUNT = STYLE_DISTANCE_EDGES.length + 1;
45
+ const SPACING_BIN_COUNT = STYLE_SPACING_EDGES.length + 1;
46
+ const FAR_FROM_BIN = STYLE_DISTANCE_EDGES.findIndex((edge) => edge === 150) + 1;
47
+ const CLOSE_BINS = 2;
48
+ const CLASS_ORDER = [
49
+ 'AR',
50
+ 'SMG',
51
+ 'DMR',
52
+ 'SR',
53
+ 'LMG',
54
+ 'SG',
55
+ 'Pistol',
56
+ 'other',
57
+ ];
58
+ const WEAPON_CLASSES = {
59
+ AR: [
60
+ 'M416',
61
+ 'BerylM762',
62
+ 'AK47',
63
+ 'AKM',
64
+ 'SCAR-L',
65
+ 'SCARL',
66
+ 'G36C',
67
+ 'QBZ',
68
+ 'AUG',
69
+ 'Groza',
70
+ 'M16A4',
71
+ 'ACE32',
72
+ 'K2',
73
+ 'FAMASG2',
74
+ 'HK416',
75
+ 'Mk47Mutant',
76
+ 'Mutant',
77
+ 'QBZ95',
78
+ ],
79
+ SMG: ['UMP', 'Vector', 'UZI', 'Thompson', 'MP5K', 'P90', 'Bizon', 'MP9', 'JS9'],
80
+ DMR: ['SKS', 'Mini14', 'Mk12', 'FNFal', 'SLR', 'VSS', 'QBU', 'Mk14', 'Dragunov'],
81
+ SR: ['Kar98k', 'M24', 'AWM', 'Win94', 'Mosin', 'Lynx', 'L6'],
82
+ LMG: ['M249', 'MG3', 'DP28', 'RPD', 'DP-28'],
83
+ SG: [
84
+ 'S12K',
85
+ 'S686',
86
+ 'S1897',
87
+ 'DBS',
88
+ 'OriginS12',
89
+ 'O12',
90
+ 'Saiga12',
91
+ 'Winchester',
92
+ 'Berreta686',
93
+ 'DP12',
94
+ ],
95
+ Pistol: [
96
+ 'P92',
97
+ 'P1911',
98
+ 'R1895',
99
+ 'R45',
100
+ 'P18C',
101
+ 'DesertEagle',
102
+ 'M9',
103
+ 'Skorpion',
104
+ 'Sawnoff',
105
+ 'G18',
106
+ 'NagantM1895',
107
+ 'vz61Skorpion',
108
+ ],
109
+ other: [],
110
+ };
111
+ export function weaponClassOf(weapon) {
112
+ const lower = weapon.toLowerCase();
113
+ for (const [name, members] of Object.entries(WEAPON_CLASSES)) {
114
+ if (members.some((member) => member.toLowerCase() === lower))
115
+ return name;
116
+ }
117
+ return 'other';
118
+ }
119
+ /** Damage categories of player-thrown or player-fired explosives and fire. */
120
+ export const EXPLOSIVE_DAMAGE = new Set([
121
+ 'Damage_Explosion_PanzerFaustWarhead',
122
+ 'Damage_Explosion_PanzerFaustWarheadVehicleArmorPenetration',
123
+ 'Damage_Explosion_Grenade',
124
+ 'Damage_Explosion_StickyBomb',
125
+ 'Damage_Explosion_Mortar',
126
+ 'Damage_Molotov',
127
+ ]);
128
+ function obj(value) {
129
+ return value && typeof value === 'object' && !Array.isArray(value)
130
+ ? value
131
+ : {};
132
+ }
133
+ function num(value) {
134
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
135
+ }
136
+ function gamePhase(event) {
137
+ const value = num(obj(event.common).isGame);
138
+ return value !== null && value >= 1 ? value : null;
139
+ }
140
+ function binIndex(value, edges) {
141
+ const index = edges.findIndex((edge) => value < edge);
142
+ return index < 0 ? edges.length : index;
143
+ }
144
+ function emptyRaw() {
145
+ return {
146
+ gunDamage: 0,
147
+ explosiveDamage: 0,
148
+ headDamage: 0,
149
+ shots: 0,
150
+ hits: 0,
151
+ hitDamage: 0,
152
+ classShots: new Array(CLASS_ORDER.length).fill(0),
153
+ classHits: new Array(CLASS_ORDER.length).fill(0),
154
+ classHitDamage: new Array(CLASS_ORDER.length).fill(0),
155
+ firstShots: 0,
156
+ firstHits: 0,
157
+ followShots: 0,
158
+ followHits: 0,
159
+ idleShots: 0,
160
+ finishingShots: 0,
161
+ dealtBins: new Array(DISTANCE_BIN_COUNT).fill(0),
162
+ phaseDamage: [0, 0, 0],
163
+ weaponDamage: {},
164
+ spacingBins: new Array(SPACING_BIN_COUNT).fill(0),
165
+ throwables: { frag: 0, smoke: 0, flash: 0, fire: 0 },
166
+ rides: 0,
167
+ healUses: 0,
168
+ boostUses: 0,
169
+ earlyPickups: 0,
170
+ footMeters: 0,
171
+ zoneSamples: 0,
172
+ zoneEdgeSamples: 0,
173
+ landingSpread: null,
174
+ };
175
+ }
176
+ function throwableKind(itemId) {
177
+ if (/SmokeBomb/iu.test(itemId))
178
+ return 'smoke';
179
+ if (/FlashBang/iu.test(itemId))
180
+ return 'flash';
181
+ if (/Molotov/iu.test(itemId))
182
+ return 'fire';
183
+ if (/Grenade/iu.test(itemId) && !/Bluezone/iu.test(itemId))
184
+ return 'frag';
185
+ return null;
186
+ }
187
+ /**
188
+ * One pass over a raw payload: engagement distance, weapons and game stage of the damage, spacing from teammates,
189
+ * utility, healing, early looting, movement, circle position, rides and landing spread.
190
+ */
191
+ export function analyzeStyleTelemetry(payload, trackedIds) {
192
+ const players = {};
193
+ for (const id of trackedIds)
194
+ players[id] = emptyRaw();
195
+ const positions = new Map();
196
+ const landings = new Map();
197
+ const landedAt = new Map();
198
+ const lastStep = new Map();
199
+ let safety = null;
200
+ // Shots are judged after the pass: whether each hit (by attackId) and whether anyone was hurt around it.
201
+ const shotLog = new Map();
202
+ const outcomes = new Map();
203
+ const contactTimes = [];
204
+ for (const raw of parseTelemetryPayload(payload)) {
205
+ const event = obj(raw);
206
+ const type = event._T;
207
+ const time = typeof event._D === 'string' ? Date.parse(event._D) : NaN;
208
+ if (type === 'LogParachuteLanding') {
209
+ const character = obj(event.character);
210
+ const id = typeof character.accountId === 'string' ? character.accountId : null;
211
+ const x = num(obj(character.location).x);
212
+ const y = num(obj(character.location).y);
213
+ if (id && trackedIds.has(id) && x !== null && y !== null) {
214
+ landings.set(id, { x: x / 100, y: y / 100 });
215
+ if (Number.isFinite(time))
216
+ landedAt.set(id, time);
217
+ }
218
+ continue;
219
+ }
220
+ const phase = gamePhase(event);
221
+ if (phase === null)
222
+ continue;
223
+ if (type === 'LogGameStatePeriodic') {
224
+ const state = obj(event.gameState);
225
+ const x = num(obj(state.safetyZonePosition).x);
226
+ const y = num(obj(state.safetyZonePosition).y);
227
+ const radius = num(state.safetyZoneRadius);
228
+ if (x !== null && y !== null && radius !== null && radius > 0)
229
+ safety = { x, y, radius };
230
+ }
231
+ else if (type === 'LogPlayerPosition') {
232
+ const character = obj(event.character);
233
+ const id = typeof character.accountId === 'string' ? character.accountId : null;
234
+ const elapsed = num(event.elapsedTime);
235
+ const x = num(obj(character.location).x);
236
+ const y = num(obj(character.location).y);
237
+ if (!id || !trackedIds.has(id) || x === null || y === null)
238
+ continue;
239
+ const entry = players[id];
240
+ const vehicle = character.isInVehicle === true;
241
+ const previous = lastStep.get(id);
242
+ if (previous && !vehicle && !previous.vehicle && Number.isFinite(time)) {
243
+ const seconds = (time - previous.time) / 1000;
244
+ const step = Math.hypot(x - previous.x, y - previous.y) / 100;
245
+ if (seconds > 0 &&
246
+ seconds <= STYLE_MAX_FOOT_GAP_SECONDS &&
247
+ step <= STYLE_MAX_FOOT_STEP_METERS)
248
+ entry.footMeters += step;
249
+ }
250
+ lastStep.set(id, { x, y, time, vehicle });
251
+ if (safety) {
252
+ entry.zoneSamples += 1;
253
+ if (Math.hypot(x - safety.x, y - safety.y) / safety.radius >= STYLE_EDGE_RATIO)
254
+ entry.zoneEdgeSamples += 1;
255
+ }
256
+ if (elapsed !== null && character.isDBNO !== true) {
257
+ const bucket = Math.floor(elapsed / STYLE_SPACING_BUCKET_SECONDS);
258
+ const slot = positions.get(bucket) ?? new Map();
259
+ slot.set(id, { x: x / 100, y: y / 100 });
260
+ positions.set(bucket, slot);
261
+ }
262
+ }
263
+ else if (type === 'LogPlayerAttack') {
264
+ const attacker = obj(event.attacker);
265
+ const id = typeof attacker.accountId === 'string' ? attacker.accountId : null;
266
+ const weapon = obj(event.weapon);
267
+ if (id &&
268
+ trackedIds.has(id) &&
269
+ event.attackType === 'Weapon' &&
270
+ weapon.category === 'Weapon' &&
271
+ (weapon.subCategory === 'Main' || weapon.subCategory === 'Handgun')) {
272
+ const fired = typeof weapon.itemId === 'string' ? normalizeWeaponName(weapon.itemId) : null;
273
+ // Launchers never register gun hits, so their shots would only drag the hit rate down.
274
+ if (fired && fired !== 'PanzerFaust' && Number.isFinite(time)) {
275
+ const log = shotLog.get(id) ?? [];
276
+ log.push({
277
+ t: time,
278
+ item: fired,
279
+ attackId: event.attackId === undefined || event.attackId === null
280
+ ? null
281
+ : String(event.attackId),
282
+ classIndex: CLASS_ORDER.indexOf(weaponClassOf(fired)),
283
+ });
284
+ shotLog.set(id, log);
285
+ }
286
+ }
287
+ }
288
+ else if (type === 'LogPlayerTakeDamage') {
289
+ const category = event.damageTypeCategory;
290
+ {
291
+ const from = obj(event.attacker);
292
+ const to = obj(event.victim);
293
+ const fromId = typeof from.accountId === 'string' ? from.accountId : null;
294
+ const toId = typeof to.accountId === 'string' ? to.accountId : null;
295
+ const amount = num(event.damage) ?? 0;
296
+ const rival = String(from.teamId ?? '') !== String(to.teamId ?? '');
297
+ const squadAttacks = fromId !== null &&
298
+ trackedIds.has(fromId) &&
299
+ !(toId !== null && trackedIds.has(toId)) &&
300
+ rival;
301
+ const squadHurt = toId !== null &&
302
+ trackedIds.has(toId) &&
303
+ fromId !== null &&
304
+ !trackedIds.has(fromId) &&
305
+ rival;
306
+ // Hits on a downed player do no damage, so a fight is also "on" while one is being finished.
307
+ if ((squadAttacks || squadHurt) &&
308
+ Number.isFinite(time) &&
309
+ (amount > 0 || to.isDBNO === true))
310
+ contactTimes.push(time);
311
+ if (squadAttacks &&
312
+ typeof category === 'string' &&
313
+ category.startsWith('Damage_Gun') &&
314
+ event.attackId !== undefined &&
315
+ event.attackId !== null) {
316
+ const key = String(event.attackId);
317
+ const outcome = outcomes.get(key) ?? { standing: false, downed: false, damage: 0 };
318
+ if (amount > 0 && to.isDBNO !== true) {
319
+ outcome.standing = true;
320
+ outcome.damage += amount;
321
+ }
322
+ else if (to.isDBNO === true)
323
+ outcome.downed = true;
324
+ outcomes.set(key, outcome);
325
+ }
326
+ }
327
+ if (typeof category === 'string' && EXPLOSIVE_DAMAGE.has(category)) {
328
+ // Explosive and fire damage counts toward damage per minute (and the rocket toward habitual weapons);
329
+ // it stays out of the hit-rate and range statistics, which only describe gunfire.
330
+ const rocketAttacker = obj(event.attacker);
331
+ const rocketVictim = obj(event.victim);
332
+ const rocketId = typeof rocketAttacker.accountId === 'string' ? rocketAttacker.accountId : null;
333
+ const rocketVictimId = typeof rocketVictim.accountId === 'string' ? rocketVictim.accountId : null;
334
+ const rocketDamage = num(event.damage);
335
+ if (rocketId &&
336
+ trackedIds.has(rocketId) &&
337
+ (rocketVictimId === null || !trackedIds.has(rocketVictimId)) &&
338
+ String(rocketAttacker.teamId ?? '') !== String(rocketVictim.teamId ?? '') &&
339
+ rocketDamage !== null &&
340
+ rocketDamage > 0) {
341
+ const rocketEntry = players[rocketId];
342
+ rocketEntry.explosiveDamage += rocketDamage;
343
+ if (category.startsWith('Damage_Explosion_PanzerFaustWarhead'))
344
+ rocketEntry.weaponDamage.PanzerFaust =
345
+ (rocketEntry.weaponDamage.PanzerFaust ?? 0) + rocketDamage;
346
+ }
347
+ continue;
348
+ }
349
+ if (category !== 'Damage_Gun' && category !== 'Damage_DBNO')
350
+ continue;
351
+ const attacker = obj(event.attacker);
352
+ const victim = obj(event.victim);
353
+ const attackerId = typeof attacker.accountId === 'string' ? attacker.accountId : null;
354
+ const victimId = typeof victim.accountId === 'string' ? victim.accountId : null;
355
+ if (!attackerId ||
356
+ !trackedIds.has(attackerId) ||
357
+ (victimId !== null && trackedIds.has(victimId)))
358
+ continue;
359
+ const damage = num(event.damage);
360
+ if (damage === null || damage <= 0)
361
+ continue;
362
+ if (String(attacker.teamId ?? '') === String(victim.teamId ?? ''))
363
+ continue;
364
+ const weapon = typeof event.damageCauserName === 'string'
365
+ ? normalizeWeaponName(event.damageCauserName)
366
+ : null;
367
+ const entry = players[attackerId];
368
+ if (category !== 'Damage_Gun')
369
+ continue;
370
+ const from = obj(attacker.location);
371
+ const to = obj(victim.location);
372
+ const dx = (num(from.x) ?? NaN) - (num(to.x) ?? NaN);
373
+ const dy = (num(from.y) ?? NaN) - (num(to.y) ?? NaN);
374
+ const dz = (num(from.z) ?? 0) - (num(to.z) ?? 0);
375
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
376
+ continue;
377
+ entry.gunDamage += damage;
378
+ if (event.damageReason === 'HeadShot')
379
+ entry.headDamage += damage;
380
+ entry.dealtBins[binIndex(Math.hypot(dx, dy, dz) / 100, STYLE_DISTANCE_EDGES)] += damage;
381
+ entry.phaseDamage[phase < 3 ? 0 : phase < STYLE_LATE_PHASE ? 1 : 2] += damage;
382
+ if (weapon)
383
+ entry.weaponDamage[weapon] = (entry.weaponDamage[weapon] ?? 0) + damage;
384
+ }
385
+ else if (type === 'LogPlayerUseThrowable') {
386
+ const id = typeof obj(event.attacker).accountId === 'string'
387
+ ? obj(event.attacker).accountId
388
+ : null;
389
+ const itemId = typeof obj(event.weapon).itemId === 'string' ? obj(event.weapon).itemId : '';
390
+ const kind = throwableKind(itemId);
391
+ if (id && trackedIds.has(id) && kind)
392
+ players[id].throwables[kind] += 1;
393
+ }
394
+ else if (type === 'LogItemUse') {
395
+ const id = typeof obj(event.character).accountId === 'string'
396
+ ? obj(event.character).accountId
397
+ : null;
398
+ const sub = obj(event.item).subCategory;
399
+ if (id && trackedIds.has(id)) {
400
+ if (sub === 'Heal')
401
+ players[id].healUses += 1;
402
+ else if (sub === 'Boost')
403
+ players[id].boostUses += 1;
404
+ }
405
+ }
406
+ else if (type === 'LogItemPickup' || type === 'LogItemPickupFromLootBox') {
407
+ const id = typeof obj(event.character).accountId === 'string'
408
+ ? obj(event.character).accountId
409
+ : null;
410
+ const landed = id ? landedAt.get(id) : undefined;
411
+ if (id &&
412
+ landed !== undefined &&
413
+ Number.isFinite(time) &&
414
+ time >= landed &&
415
+ time - landed <= STYLE_EARLY_LOOT_SECONDS * 1000)
416
+ players[id].earlyPickups += 1;
417
+ }
418
+ else if (type === 'LogVehicleRide') {
419
+ const character = obj(event.character);
420
+ const id = typeof character.accountId === 'string' ? character.accountId : null;
421
+ const vehicleType = typeof obj(event.vehicle).vehicleType === 'string'
422
+ ? obj(event.vehicle).vehicleType
423
+ : '';
424
+ if (id && trackedIds.has(id) && !/Aircraft/iu.test(vehicleType))
425
+ players[id].rides += 1;
426
+ }
427
+ }
428
+ contactTimes.sort((left, right) => left - right);
429
+ const fightAround = (t) => {
430
+ let low = 0;
431
+ let high = contactTimes.length;
432
+ while (low < high) {
433
+ const mid = (low + high) >> 1;
434
+ if (contactTimes[mid] < t - STYLE_ENGAGE_WINDOW_MS)
435
+ low = mid + 1;
436
+ else
437
+ high = mid;
438
+ }
439
+ return low < contactTimes.length && contactTimes[low] <= t + STYLE_ENGAGE_WINDOW_MS;
440
+ };
441
+ for (const [id, log] of shotLog) {
442
+ const entry = players[id];
443
+ log.sort((left, right) => left.t - right.t);
444
+ const lastShot = new Map();
445
+ for (const shot of log) {
446
+ const outcome = shot.attackId === null ? undefined : outcomes.get(shot.attackId);
447
+ const previous = lastShot.get(shot.item);
448
+ lastShot.set(shot.item, shot.t);
449
+ if (outcome?.downed && !outcome.standing) {
450
+ entry.finishingShots += 1;
451
+ continue;
452
+ }
453
+ if (!fightAround(shot.t)) {
454
+ entry.idleShots += 1;
455
+ continue;
456
+ }
457
+ const hit = outcome?.standing === true;
458
+ const follow = previous !== undefined && shot.t - previous <= STYLE_BURST_GAP_MS;
459
+ entry.shots += 1;
460
+ entry.classShots[shot.classIndex] += 1;
461
+ if (hit) {
462
+ entry.hits += 1;
463
+ entry.hitDamage += outcome.damage;
464
+ entry.classHits[shot.classIndex] += 1;
465
+ entry.classHitDamage[shot.classIndex] += outcome.damage;
466
+ }
467
+ if (follow) {
468
+ entry.followShots += 1;
469
+ if (hit)
470
+ entry.followHits += 1;
471
+ }
472
+ else {
473
+ entry.firstShots += 1;
474
+ if (hit)
475
+ entry.firstHits += 1;
476
+ }
477
+ }
478
+ }
479
+ for (const slot of positions.values()) {
480
+ if (slot.size < 2)
481
+ continue;
482
+ for (const [id, here] of slot) {
483
+ let nearest = Infinity;
484
+ for (const [other, there] of slot)
485
+ if (other !== id)
486
+ nearest = Math.min(nearest, Math.hypot(here.x - there.x, here.y - there.y));
487
+ players[id].spacingBins[binIndex(nearest, STYLE_SPACING_EDGES)] += 1;
488
+ }
489
+ }
490
+ if (landings.size) {
491
+ const cx = [...landings.values()].reduce((sum, point) => sum + point.x, 0) / landings.size;
492
+ const cy = [...landings.values()].reduce((sum, point) => sum + point.y, 0) / landings.size;
493
+ for (const [id, point] of landings)
494
+ players[id].landingSpread = Math.hypot(point.x - cx, point.y - cy);
495
+ }
496
+ return players;
497
+ }
498
+ export function emptyFights() {
499
+ return emptyRoleCounts();
500
+ }
501
+ // ---------------------------------------------------------------------------------------------------------------
502
+ // Flat vector layout: sums, subtraction (for "teammates only" baselines) and bootstrap resampling are just array maths.
503
+ const FIGHT_KEYS = Object.keys(emptyFights());
504
+ const SCALARS = [
505
+ 'minutes',
506
+ 'gunDamage',
507
+ 'explosiveDamage',
508
+ 'headDamage',
509
+ 'shots',
510
+ 'hits',
511
+ 'hitDamage',
512
+ 'firstShots',
513
+ 'firstHits',
514
+ 'followShots',
515
+ 'followHits',
516
+ 'idleShots',
517
+ 'finishingShots',
518
+ 'throwables',
519
+ 'rides',
520
+ 'healUses',
521
+ 'boostUses',
522
+ 'earlyPickups',
523
+ 'footMeters',
524
+ 'zoneSamples',
525
+ 'zoneEdgeSamples',
526
+ 'pickupMatches',
527
+ ];
528
+ const OFFSET = {};
529
+ let cursor = 0;
530
+ for (const key of SCALARS) {
531
+ OFFSET[key] = cursor;
532
+ cursor += 1;
533
+ }
534
+ for (const key of FIGHT_KEYS) {
535
+ OFFSET[`f:${key}`] = cursor;
536
+ cursor += 1;
537
+ }
538
+ OFFSET.dealtBins = cursor;
539
+ cursor += DISTANCE_BIN_COUNT;
540
+ OFFSET.spacingBins = cursor;
541
+ cursor += SPACING_BIN_COUNT;
542
+ OFFSET.phaseDamage = cursor;
543
+ cursor += 3;
544
+ OFFSET.classDamage = cursor;
545
+ cursor += CLASS_ORDER.length;
546
+ OFFSET.classShots = cursor;
547
+ cursor += CLASS_ORDER.length;
548
+ OFFSET.classHits = cursor;
549
+ cursor += CLASS_ORDER.length;
550
+ OFFSET.classHitDamage = cursor;
551
+ cursor += CLASS_ORDER.length;
552
+ const VECTOR_LENGTH = cursor;
553
+ function toVector(stats) {
554
+ const vector = new Float64Array(VECTOR_LENGTH);
555
+ vector[OFFSET.minutes] = stats.minutes;
556
+ vector[OFFSET.gunDamage] = stats.gunDamage;
557
+ vector[OFFSET.explosiveDamage] = stats.explosiveDamage;
558
+ vector[OFFSET.headDamage] = stats.headDamage;
559
+ vector[OFFSET.shots] = stats.shots;
560
+ vector[OFFSET.hits] = stats.hits;
561
+ vector[OFFSET.hitDamage] = stats.hitDamage;
562
+ vector[OFFSET.firstShots] = stats.firstShots;
563
+ vector[OFFSET.firstHits] = stats.firstHits;
564
+ vector[OFFSET.followShots] = stats.followShots;
565
+ vector[OFFSET.followHits] = stats.followHits;
566
+ vector[OFFSET.idleShots] = stats.idleShots;
567
+ vector[OFFSET.finishingShots] = stats.finishingShots;
568
+ vector[OFFSET.throwables] =
569
+ stats.throwables.frag + stats.throwables.smoke + stats.throwables.flash + stats.throwables.fire;
570
+ vector[OFFSET.rides] = stats.rides;
571
+ vector[OFFSET.healUses] = stats.healUses;
572
+ vector[OFFSET.boostUses] = stats.boostUses;
573
+ vector[OFFSET.earlyPickups] = stats.earlyPickups;
574
+ vector[OFFSET.footMeters] = stats.footMeters;
575
+ vector[OFFSET.zoneSamples] = stats.zoneSamples;
576
+ vector[OFFSET.zoneEdgeSamples] = stats.zoneEdgeSamples;
577
+ vector[OFFSET.pickupMatches] = stats.landingSpread !== null || stats.earlyPickups > 0 ? 1 : 0;
578
+ for (const key of FIGHT_KEYS)
579
+ vector[OFFSET[`f:${key}`]] = stats.fights[key];
580
+ stats.dealtBins.forEach((value, index) => {
581
+ vector[OFFSET.dealtBins + index] = value;
582
+ });
583
+ stats.spacingBins.forEach((value, index) => {
584
+ vector[OFFSET.spacingBins + index] = value;
585
+ });
586
+ stats.phaseDamage.forEach((value, index) => {
587
+ vector[OFFSET.phaseDamage + index] = value;
588
+ });
589
+ stats.classShots.forEach((value, index) => {
590
+ vector[OFFSET.classShots + index] = value;
591
+ });
592
+ stats.classHits.forEach((value, index) => {
593
+ vector[OFFSET.classHits + index] = value;
594
+ });
595
+ stats.classHitDamage.forEach((value, index) => {
596
+ vector[OFFSET.classHitDamage + index] = value;
597
+ });
598
+ for (const [weapon, damage] of Object.entries(stats.weaponDamage)) {
599
+ vector[OFFSET.classDamage + CLASS_ORDER.indexOf(weaponClassOf(weapon))] += damage;
600
+ }
601
+ return vector;
602
+ }
603
+ function addVec(target, source) {
604
+ for (let index = 0; index < target.length; index += 1)
605
+ target[index] += source[index];
606
+ }
607
+ function subtractVec(whole, part) {
608
+ const result = new Float64Array(whole.length);
609
+ for (let index = 0; index < whole.length; index += 1)
610
+ result[index] = Math.max(0, whole[index] - part[index]);
611
+ return result;
612
+ }
613
+ function slice(vector, key, length) {
614
+ return Array.from(vector.subarray(OFFSET[key], OFFSET[key] + length));
615
+ }
616
+ function scalar(vector, key) {
617
+ return vector[OFFSET[key]];
618
+ }
619
+ function fight(vector, key) {
620
+ return vector[OFFSET[`f:${key}`]];
621
+ }
622
+ /** Damage-weighted median engagement distance in metres, interpolated inside the winning bin. */
623
+ export function weightedMedianDistance(bins) {
624
+ const total = bins.reduce((sum, value) => sum + value, 0);
625
+ if (total <= 0)
626
+ return null;
627
+ let running = 0;
628
+ for (let index = 0; index < bins.length; index += 1) {
629
+ const next = running + bins[index];
630
+ if (next >= total / 2) {
631
+ const low = index === 0 ? 0 : STYLE_DISTANCE_EDGES[index - 1];
632
+ const high = index < STYLE_DISTANCE_EDGES.length ? STYLE_DISTANCE_EDGES[index] : low * 1.5;
633
+ const within = bins[index] > 0 ? (total / 2 - running) / bins[index] : 0;
634
+ return low + (high - low) * within;
635
+ }
636
+ running = next;
637
+ }
638
+ return null;
639
+ }
640
+ function share(part, whole) {
641
+ return whole > 0 ? part / whole : null;
642
+ }
643
+ /**
644
+ * Axes that make up the fighting-power index and their weights. Chosen on 112 ranked squad matches: damage per
645
+ * minute tracked fight wins and placement best; headshot share was unreliable (split-half 0.45) and unrelated to
646
+ * winning, and knock conversion depends on teammates finishing, so both stay as separate axes only.
647
+ */
648
+ export const COMBAT_WEIGHTS = {
649
+ dpm: 0.5,
650
+ knockRate: 0.25,
651
+ hitRate: 0.25,
652
+ };
653
+ export const COMBAT_COMPONENTS = Object.keys(COMBAT_WEIGHTS);
654
+ export const STYLE_AXES = [
655
+ {
656
+ key: 'combat',
657
+ group: '战斗效率',
658
+ label: '战斗力指数',
659
+ high: '综合输出明显强于队友',
660
+ low: '综合输出弱于队友',
661
+ composite: COMBAT_COMPONENTS,
662
+ },
663
+ {
664
+ key: 'dpm',
665
+ group: '战斗效率',
666
+ label: '每分钟伤害(含爆炸)',
667
+ high: '每存活分钟打出更多伤害',
668
+ low: '每存活分钟伤害更少',
669
+ },
670
+ {
671
+ key: 'hitRate',
672
+ group: '战斗效率',
673
+ label: '命中率(同枪型)',
674
+ high: '同样的枪,命中率更高',
675
+ low: '同样的枪,命中率更低',
676
+ },
677
+ {
678
+ key: 'dmgPerShot',
679
+ group: '战斗效率',
680
+ label: '每发伤害(同枪型)',
681
+ high: '同样的枪,每发打出更多伤害',
682
+ low: '同样的枪,每发伤害更少',
683
+ },
684
+ {
685
+ key: 'knockRate',
686
+ group: '战斗效率',
687
+ label: '交战击倒率',
688
+ high: '更多交战里打倒了人',
689
+ low: '更少在交战里打倒人',
690
+ },
691
+ {
692
+ key: 'conversion',
693
+ group: '战斗效率',
694
+ label: '击倒转化率',
695
+ high: '打倒的人更常被终结',
696
+ low: '打倒的人更常被救起',
697
+ },
698
+ { key: 'range', group: '射击', label: '交战距离', high: '比队友打得更远', low: '比队友打得更近' },
699
+ {
700
+ key: 'farShare',
701
+ group: '射击',
702
+ label: '远距输出占比',
703
+ high: '更多输出来自 150m 外',
704
+ low: '很少在 150m 外输出',
705
+ },
706
+ {
707
+ key: 'closeShare',
708
+ group: '射击',
709
+ label: '近距输出占比',
710
+ high: '更多输出发生在 25m 内',
711
+ low: '很少贴脸输出',
712
+ },
713
+ {
714
+ key: 'marksman',
715
+ group: '射击',
716
+ label: '狙击/射手枪占比',
717
+ high: '偏爱狙击和射手步枪',
718
+ low: '很少用狙击和射手步枪',
719
+ },
720
+ {
721
+ key: 'closeGun',
722
+ group: '射击',
723
+ label: '冲锋/霰弹/手枪占比',
724
+ high: '偏爱近战武器',
725
+ low: '很少用近战武器',
726
+ },
727
+ { key: 'precision', group: '射击', label: '爆头率', high: '爆头占比更高', low: '爆头占比更低' },
728
+ {
729
+ key: 'firstShot',
730
+ group: '射击',
731
+ label: '首发命中率',
732
+ high: '首发和单点射击更准',
733
+ low: '首发和单点射击命中偏低',
734
+ },
735
+ {
736
+ key: 'followShot',
737
+ group: '射击',
738
+ label: '连射命中率',
739
+ high: '连射时后续子弹更准',
740
+ low: '连射时后续子弹偏飘',
741
+ },
742
+ { key: 'burst', group: '射击', label: '连射占比', high: '更爱连射扫射', low: '更爱点射' },
743
+ { key: 'tempo', group: '节奏', label: '参战频率', high: '更爱找交战', low: '参战更少,更低调' },
744
+ {
745
+ key: 'initiative',
746
+ group: '节奏',
747
+ label: '先手比例',
748
+ high: '更常第一个开火',
749
+ low: '更多是被动接战',
750
+ },
751
+ {
752
+ key: 'fragging',
753
+ group: '节奏',
754
+ label: '收割效率',
755
+ high: '每次交战击倒/击杀更多',
756
+ low: '更多承担牵制和辅助',
757
+ },
758
+ {
759
+ key: 'lateGame',
760
+ group: '节奏',
761
+ label: '后期输出占比',
762
+ high: '输出集中在后期(第 5 圈起)',
763
+ low: '输出集中在前中期',
764
+ },
765
+ {
766
+ key: 'earlyLoot',
767
+ group: '节奏',
768
+ label: '开局搜刮速度',
769
+ high: '落地后搜刮更快',
770
+ low: '落地后搜刮更慢',
771
+ },
772
+ { key: 'revive', group: '协作与补给', label: '救援', high: '救援更积极', low: '较少救人' },
773
+ {
774
+ key: 'utility',
775
+ group: '协作与补给',
776
+ label: '投掷物运用',
777
+ high: '更爱用雷和烟',
778
+ low: '很少用投掷物',
779
+ },
780
+ {
781
+ key: 'supplies',
782
+ group: '协作与补给',
783
+ label: '补给使用频率',
784
+ high: '更勤于打药补状态',
785
+ low: '很少打药',
786
+ },
787
+ {
788
+ key: 'isolation',
789
+ group: '协作与补给',
790
+ label: '脱队程度',
791
+ high: '更常脱离队友',
792
+ low: '更紧贴队友',
793
+ },
794
+ { key: 'risk', group: '位置与风险', label: '受伤风险', high: '更常被打倒', low: '很少被打倒' },
795
+ {
796
+ key: 'zoneEdge',
797
+ group: '位置与风险',
798
+ label: '圈边位置占比',
799
+ high: '更常待在圈边或圈外',
800
+ low: '更靠近圈中心',
801
+ },
802
+ { key: 'foot', group: '位置与风险', label: '跑动量', high: '步行转移更多', low: '步行转移更少' },
803
+ { key: 'mobility', group: '位置与风险', label: '载具使用', high: '更爱开车', low: '很少用载具' },
804
+ ];
805
+ /** Aimed shots, hits and standing-target damage. Shotguns need no special case: shots are paired with hits by attack. */
806
+ function mixTotals(vector) {
807
+ return {
808
+ shots: scalar(vector, 'shots'),
809
+ hits: scalar(vector, 'hits'),
810
+ hitDamage: scalar(vector, 'hitDamage'),
811
+ };
812
+ }
813
+ /** Raw metric for one axis from a summed vector; null when there is too little data to measure it. */
814
+ export function axisValue(key, vector) {
815
+ const fights = fight(vector, 'fights');
816
+ const gun = scalar(vector, 'gunDamage');
817
+ const minutes = scalar(vector, 'minutes');
818
+ const dealt = slice(vector, 'dealtBins', DISTANCE_BIN_COUNT);
819
+ const classes = slice(vector, 'classDamage', CLASS_ORDER.length);
820
+ const classTotal = classes.reduce((sum, value) => sum + value, 0);
821
+ const cls = (...names) => names.reduce((sum, name) => sum + classes[CLASS_ORDER.indexOf(name)], 0);
822
+ const enoughFights = fights >= STYLE_MIN_FIGHTS;
823
+ const enoughDamage = gun >= STYLE_MIN_DAMAGE;
824
+ const enoughTime = minutes >= STYLE_MIN_MINUTES;
825
+ switch (key) {
826
+ case 'range':
827
+ return enoughDamage ? weightedMedianDistance(dealt) : null;
828
+ case 'farShare':
829
+ return enoughDamage
830
+ ? share(dealt.slice(FAR_FROM_BIN).reduce((sum, value) => sum + value, 0), gun)
831
+ : null;
832
+ case 'closeShare':
833
+ return enoughDamage
834
+ ? share(dealt.slice(0, CLOSE_BINS).reduce((sum, value) => sum + value, 0), gun)
835
+ : null;
836
+ case 'marksman':
837
+ return classTotal >= STYLE_MIN_DAMAGE ? share(cls('SR', 'DMR'), classTotal) : null;
838
+ case 'closeGun':
839
+ return classTotal >= STYLE_MIN_DAMAGE ? share(cls('SMG', 'SG', 'Pistol'), classTotal) : null;
840
+ case 'precision':
841
+ return enoughDamage ? share(scalar(vector, 'headDamage'), gun) : null;
842
+ case 'tempo':
843
+ return enoughTime ? fights / minutes : null;
844
+ case 'initiative':
845
+ return enoughFights ? fight(vector, 'initiations') / fights : null;
846
+ case 'fragging':
847
+ return enoughFights ? (fight(vector, 'kills') + fight(vector, 'knocksDealt')) / fights : null;
848
+ case 'lateGame': {
849
+ const phases = slice(vector, 'phaseDamage', 3);
850
+ return enoughDamage
851
+ ? share(phases[2], phases.reduce((sum, value) => sum + value, 0))
852
+ : null;
853
+ }
854
+ case 'earlyLoot':
855
+ return scalar(vector, 'pickupMatches') >= STYLE_MIN_PICKUP_MATCHES
856
+ ? scalar(vector, 'earlyPickups') / scalar(vector, 'pickupMatches')
857
+ : null;
858
+ case 'revive':
859
+ return enoughFights ? fight(vector, 'revives') / fights : null;
860
+ case 'utility':
861
+ return enoughFights ? scalar(vector, 'throwables') / fights : null;
862
+ case 'supplies':
863
+ return enoughTime
864
+ ? (scalar(vector, 'healUses') + scalar(vector, 'boostUses')) / minutes
865
+ : null;
866
+ case 'isolation': {
867
+ const bins = slice(vector, 'spacingBins', SPACING_BIN_COUNT);
868
+ const total = bins.reduce((sum, value) => sum + value, 0);
869
+ const firstIsolated = STYLE_SPACING_EDGES.findIndex((edge) => edge >= STYLE_ISOLATED_METERS);
870
+ return total >= STYLE_MIN_SPACING_SAMPLES
871
+ ? bins
872
+ .slice(firstIsolated < 0 ? STYLE_SPACING_EDGES.length : firstIsolated + 1)
873
+ .reduce((sum, value) => sum + value, 0) / total
874
+ : null;
875
+ }
876
+ case 'risk':
877
+ return enoughFights ? fight(vector, 'timesDowned') / fights : null;
878
+ case 'zoneEdge':
879
+ return scalar(vector, 'zoneSamples') >= STYLE_MIN_ZONE_SAMPLES
880
+ ? scalar(vector, 'zoneEdgeSamples') / scalar(vector, 'zoneSamples')
881
+ : null;
882
+ case 'foot':
883
+ return enoughTime ? scalar(vector, 'footMeters') / minutes : null;
884
+ case 'mobility':
885
+ return enoughTime ? scalar(vector, 'rides') / minutes : null;
886
+ case 'dpm':
887
+ return enoughTime ? (gun + scalar(vector, 'explosiveDamage')) / minutes : null;
888
+ case 'hitRate': {
889
+ const shot = mixTotals(vector);
890
+ return shot.shots >= STYLE_MIN_SHOTS ? shot.hits / shot.shots : null;
891
+ }
892
+ case 'firstShot':
893
+ return scalar(vector, 'firstShots') >= STYLE_MIN_SPLIT_SHOTS
894
+ ? scalar(vector, 'firstHits') / scalar(vector, 'firstShots')
895
+ : null;
896
+ case 'followShot':
897
+ return scalar(vector, 'followShots') >= STYLE_MIN_SPLIT_SHOTS
898
+ ? scalar(vector, 'followHits') / scalar(vector, 'followShots')
899
+ : null;
900
+ case 'burst':
901
+ return scalar(vector, 'shots') >= STYLE_MIN_SHOTS
902
+ ? scalar(vector, 'followShots') / scalar(vector, 'shots')
903
+ : null;
904
+ case 'dmgPerShot': {
905
+ const shot = mixTotals(vector);
906
+ return shot.shots >= STYLE_MIN_SHOTS ? shot.hitDamage / shot.shots : null;
907
+ }
908
+ case 'knockRate':
909
+ return enoughFights ? fight(vector, 'fightsWithKnock') / fights : null;
910
+ case 'conversion':
911
+ return fight(vector, 'chainsDealt') >= STYLE_MIN_KNOCKS
912
+ ? fight(vector, 'chainsFinished') / fight(vector, 'chainsDealt')
913
+ : null;
914
+ case 'combat':
915
+ return null;
916
+ }
917
+ }
918
+ const ARCHETYPES = [
919
+ {
920
+ id: 'rusher',
921
+ label: '近战突击手',
922
+ weights: { range: -1, closeShare: 0.8, closeGun: 0.4, initiative: 0.5, tempo: 0.3 },
923
+ blurb: '喜欢贴脸开团、抢先手',
924
+ },
925
+ {
926
+ id: 'marksman',
927
+ label: '远程火力手',
928
+ weights: { range: 1, farShare: 0.8, marksman: 0.6, precision: 0.3 },
929
+ blurb: '在中远距离提供火力',
930
+ },
931
+ {
932
+ id: 'fragger',
933
+ label: '火力收割手',
934
+ weights: { fragging: 1, tempo: 0.3, precision: 0.3 },
935
+ blurb: '交战里击倒和收人头更多',
936
+ },
937
+ {
938
+ id: 'roamer',
939
+ label: '游击单兵',
940
+ weights: { isolation: 1, foot: 0.5, mobility: 0.3 },
941
+ blurb: '常脱离队友单独行动',
942
+ },
943
+ {
944
+ id: 'support',
945
+ label: '后勤支援',
946
+ weights: { revive: 1, utility: 0.5, supplies: 0.3, fragging: -0.3 },
947
+ blurb: '救援和补位见长',
948
+ },
949
+ {
950
+ id: 'anchor',
951
+ label: '贴队稳健型',
952
+ weights: { isolation: -1, risk: -0.4, zoneEdge: -0.2 },
953
+ blurb: '紧跟队友、很少冒险',
954
+ },
955
+ {
956
+ id: 'vanguard',
957
+ label: '前排先锋',
958
+ weights: { risk: 1, tempo: 0.4, initiative: 0.3 },
959
+ blurb: '站在最前面承受火力',
960
+ },
961
+ {
962
+ id: 'late',
963
+ label: '后期输出型',
964
+ weights: { lateGame: 1, earlyLoot: -0.2, tempo: -0.2 },
965
+ blurb: '前期低调、后期才发力',
966
+ },
967
+ {
968
+ id: 'carry',
969
+ label: '输出核心',
970
+ weights: { combat: 1, dpm: 0.4, hitRate: 0.3 },
971
+ blurb: '综合输出明显强于队友',
972
+ },
973
+ {
974
+ id: 'scavenger',
975
+ label: '发育搜刮型',
976
+ weights: { earlyLoot: 1, supplies: 0.3, lateGame: 0.2 },
977
+ blurb: '落地后搜刮快、补给足',
978
+ },
979
+ ];
980
+ function clip(value, limit = 1.2) {
981
+ return Math.max(-limit, Math.min(limit, value));
982
+ }
983
+ export function mulberry32(seed) {
984
+ let state = seed >>> 0;
985
+ return () => {
986
+ state = (state + 0x6d2b79f5) >>> 0;
987
+ let t = state;
988
+ t = Math.imul(t ^ (t >>> 15), t | 1);
989
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
990
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
991
+ };
992
+ }
993
+ export function percentile(sorted, fraction) {
994
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(fraction * (sorted.length - 1))))];
995
+ }
996
+ function sumMatches(matches, indexes) {
997
+ const perPlayer = new Map();
998
+ const pooled = new Float64Array(VECTOR_LENGTH);
999
+ for (const index of indexes) {
1000
+ for (const [accountId, stats] of Object.entries(matches[index].players)) {
1001
+ const vector = toVector(stats);
1002
+ const target = perPlayer.get(accountId) ?? new Float64Array(VECTOR_LENGTH);
1003
+ addVec(target, vector);
1004
+ perPlayer.set(accountId, target);
1005
+ addVec(pooled, vector);
1006
+ }
1007
+ }
1008
+ return { perPlayer, pooled };
1009
+ }
1010
+ function ratioOf(own, base) {
1011
+ return own !== null && base !== null && base > 0 ? own / base : null;
1012
+ }
1013
+ /** Axes whose raw value depends on which weapons were fired; compared on the player's own weapon mix. */
1014
+ const MIX_ADJUSTED = new Set(['hitRate', 'dmgPerShot']);
1015
+ /**
1016
+ * The player's value and what the teammates would score firing the player's own weapon mix (indirect
1017
+ * standardisation by weapon class). Without it a sniper out-"aims" a sprayer simply by using a bolt-action.
1018
+ * Each teammate class rate is shrunk toward the teammates' overall rate with a STYLE_CLASS_PRIOR_SHOTS prior.
1019
+ */
1020
+ function axisPair(key, own, mates) {
1021
+ const value = axisValue(key, own);
1022
+ if (!MIX_ADJUSTED.has(key))
1023
+ return { value, matesValue: axisValue(key, mates) };
1024
+ const matesMix = mixTotals(mates);
1025
+ if (value === null || matesMix.shots < STYLE_MIN_SHOTS)
1026
+ return { value, matesValue: null };
1027
+ const useDamage = key === 'dmgPerShot';
1028
+ const matesOverall = (useDamage ? matesMix.hitDamage : matesMix.hits) / matesMix.shots;
1029
+ const ownShots = slice(own, 'classShots', CLASS_ORDER.length);
1030
+ const mateShots = slice(mates, 'classShots', CLASS_ORDER.length);
1031
+ const mateYield = slice(mates, useDamage ? 'classHitDamage' : 'classHits', CLASS_ORDER.length);
1032
+ let expected = 0;
1033
+ let shots = 0;
1034
+ CLASS_ORDER.forEach((weaponClass, index) => {
1035
+ const rate = (mateYield[index] + STYLE_CLASS_PRIOR_SHOTS * matesOverall) /
1036
+ (mateShots[index] + STYLE_CLASS_PRIOR_SHOTS);
1037
+ expected += ownShots[index] * rate;
1038
+ shots += ownShots[index];
1039
+ });
1040
+ return { value, matesValue: shots > 0 ? expected / shots : null };
1041
+ }
1042
+ function pairRatio(key, own, mates) {
1043
+ const { value, matesValue } = axisPair(key, own, mates);
1044
+ return ratioOf(value, matesValue);
1045
+ }
1046
+ export function tierFor(ratio, low, high, confidence) {
1047
+ if (ratio === null)
1048
+ return { tier: 'unknown', direction: null };
1049
+ const direction = ratio >= 1 ? 'high' : 'low';
1050
+ const notable = ratio >= STYLE_NOTABLE_RATIO || ratio <= 1 / STYLE_NOTABLE_RATIO;
1051
+ if (!notable)
1052
+ return { tier: 'even', direction };
1053
+ if (low !== null && high !== null && (low > 1 || high < 1))
1054
+ return { tier: 'stable', direction };
1055
+ if (confidence !== null && confidence >= STYLE_LEAN_CONFIDENCE)
1056
+ return { tier: 'lean', direction };
1057
+ return { tier: 'even', direction };
1058
+ }
1059
+ /**
1060
+ * One style profile per member: nineteen measurable axes, each expressed as a ratio to the player's teammates
1061
+ * (excluding the player) with a bootstrap interval over matches. Differences are graded stable, lean or even,
1062
+ * so a squad that plays alike still gets honest, ranked tendencies instead of an empty card.
1063
+ */
1064
+ export function buildSquadStyle(input) {
1065
+ const { matches } = input;
1066
+ const all = matches.map((_, index) => index);
1067
+ const base = sumMatches(matches, all);
1068
+ const accountIds = [...base.perPlayer.keys()];
1069
+ const rng = mulberry32(STYLE_BOOTSTRAP_SEED);
1070
+ const composite = STYLE_AXES.find((axis) => axis.composite);
1071
+ const draws = new Map(accountIds.map((id) => [id, new Map(STYLE_AXES.map((axis) => [axis.key, []]))]));
1072
+ /** Weighted mean log2 ratio of the composite's components; needs damage per minute plus one other. */
1073
+ const compositeLog = (logs) => {
1074
+ const present = (composite.composite ?? []).filter((key) => logs.has(key));
1075
+ if (!logs.has('dpm') || present.length < 2)
1076
+ return null;
1077
+ const weight = present.reduce((sum, key) => sum + (COMBAT_WEIGHTS[key] ?? 0), 0);
1078
+ return (present.reduce((sum, key) => sum + logs.get(key) * (COMBAT_WEIGHTS[key] ?? 0), 0) / weight);
1079
+ };
1080
+ if (matches.length >= 8) {
1081
+ for (let round = 0; round < STYLE_BOOTSTRAP_RESAMPLES; round += 1) {
1082
+ const draw = Array.from({ length: matches.length }, () => Math.floor(rng() * matches.length));
1083
+ const resampled = sumMatches(matches, draw);
1084
+ for (const id of accountIds) {
1085
+ const own = resampled.perPlayer.get(id);
1086
+ if (!own)
1087
+ continue;
1088
+ const mates = subtractVec(resampled.pooled, own);
1089
+ const logs = new Map();
1090
+ for (const axis of STYLE_AXES) {
1091
+ if (axis.composite)
1092
+ continue;
1093
+ const value = pairRatio(axis.key, own, mates);
1094
+ if (value !== null && value > 0) {
1095
+ logs.set(axis.key, Math.log2(value));
1096
+ draws.get(id).get(axis.key).push(Math.log2(value));
1097
+ }
1098
+ }
1099
+ const combined = compositeLog(logs);
1100
+ if (combined !== null)
1101
+ draws.get(id).get(composite.key).push(combined);
1102
+ }
1103
+ }
1104
+ }
1105
+ const readingsByPlayer = new Map();
1106
+ for (const accountId of accountIds) {
1107
+ const own = base.perPlayer.get(accountId);
1108
+ const mates = subtractVec(base.pooled, own);
1109
+ const baseLogs = new Map();
1110
+ for (const axis of STYLE_AXES) {
1111
+ if (axis.composite)
1112
+ continue;
1113
+ const value = pairRatio(axis.key, own, mates);
1114
+ if (value !== null && value > 0)
1115
+ baseLogs.set(axis.key, Math.log2(value));
1116
+ }
1117
+ readingsByPlayer.set(accountId, STYLE_AXES.map((axis) => {
1118
+ let value;
1119
+ let matesValue;
1120
+ let ratio;
1121
+ if (axis.composite) {
1122
+ const combined = compositeLog(baseLogs);
1123
+ ratio = combined === null ? null : 2 ** combined;
1124
+ value = ratio;
1125
+ matesValue = ratio === null ? null : 1;
1126
+ }
1127
+ else {
1128
+ ({ value, matesValue } = axisPair(axis.key, own, mates));
1129
+ ratio = ratioOf(value, matesValue);
1130
+ }
1131
+ const sorted = [...(draws.get(accountId).get(axis.key) ?? [])].sort((left, right) => left - right);
1132
+ const enough = sorted.length >= STYLE_BOOTSTRAP_RESAMPLES * 0.8;
1133
+ const low = enough ? 2 ** percentile(sorted, 0.025) : null;
1134
+ const high = enough ? 2 ** percentile(sorted, 0.975) : null;
1135
+ const confidence = enough && ratio !== null
1136
+ ? sorted.filter((draw) => (ratio >= 1 ? draw > 0 : draw < 0)).length / sorted.length
1137
+ : null;
1138
+ const { tier, direction } = tierFor(ratio, low, high, confidence);
1139
+ return {
1140
+ key: axis.key,
1141
+ group: axis.group,
1142
+ label: axis.label,
1143
+ value,
1144
+ matesValue,
1145
+ ratio,
1146
+ low,
1147
+ high,
1148
+ confidence,
1149
+ tier,
1150
+ direction,
1151
+ stable: tier === 'stable',
1152
+ };
1153
+ }));
1154
+ }
1155
+ const players = accountIds
1156
+ .map((accountId) => {
1157
+ const axes = readingsByPlayer.get(accountId);
1158
+ const traits = axes
1159
+ .filter((axis) => (axis.tier === 'stable' || axis.tier === 'lean') &&
1160
+ axis.ratio !== null &&
1161
+ !STYLE_AXES.find((item) => item.key === axis.key)?.composite)
1162
+ .map((axis) => {
1163
+ const definition = STYLE_AXES.find((item) => item.key === axis.key);
1164
+ return {
1165
+ key: axis.key,
1166
+ text: axis.direction === 'high' ? definition.high : definition.low,
1167
+ ratio: axis.ratio,
1168
+ tier: axis.tier,
1169
+ confidence: axis.confidence,
1170
+ };
1171
+ })
1172
+ .sort((left, right) => (left.tier === right.tier ? 0 : left.tier === 'stable' ? -1 : 1) ||
1173
+ Math.abs(Math.log2(right.ratio)) - Math.abs(Math.log2(left.ratio)))
1174
+ .slice(0, 5);
1175
+ const scoreOf = (weights) => {
1176
+ let total = 0;
1177
+ let stable = false;
1178
+ for (const [key, weight] of Object.entries(weights)) {
1179
+ const reading = axes.find((axis) => axis.key === key);
1180
+ if (!reading?.ratio || reading.tier === 'unknown')
1181
+ continue;
1182
+ const factor = reading.tier === 'stable' ? 1 : reading.tier === 'lean' ? 0.6 : STYLE_EVEN_FACTOR;
1183
+ const contribution = weight * clip(Math.log2(reading.ratio)) * factor;
1184
+ total += contribution;
1185
+ if (reading.tier === 'stable' && contribution > 0)
1186
+ stable = true;
1187
+ }
1188
+ return { score: total, stable };
1189
+ };
1190
+ const ranked = ARCHETYPES.map((archetype) => ({
1191
+ archetype,
1192
+ ...scoreOf(archetype.weights),
1193
+ })).sort((left, right) => right.score - left.score);
1194
+ const tierOf = (entry) => entry.score >= STYLE_ARCHETYPE_STRONG && entry.stable
1195
+ ? 'stable'
1196
+ : entry.score >= STYLE_ARCHETYPE_LEAN
1197
+ ? 'lean'
1198
+ : entry.score >= STYLE_ARCHETYPE_SLIGHT
1199
+ ? 'slight'
1200
+ : null;
1201
+ const archetypes = [];
1202
+ const first = ranked[0];
1203
+ const firstTier = tierOf(first);
1204
+ if (firstTier) {
1205
+ archetypes.push({
1206
+ id: first.archetype.id,
1207
+ label: first.archetype.label,
1208
+ score: first.score,
1209
+ tier: firstTier,
1210
+ });
1211
+ const second = ranked[1];
1212
+ const secondTier = tierOf(second);
1213
+ if (secondTier && secondTier !== 'slight')
1214
+ archetypes.push({
1215
+ id: second.archetype.id,
1216
+ label: second.archetype.label,
1217
+ score: second.score,
1218
+ tier: secondTier,
1219
+ });
1220
+ }
1221
+ else {
1222
+ archetypes.push({
1223
+ id: 'balanced',
1224
+ label: '均衡全能型',
1225
+ score: first.score,
1226
+ tier: 'balanced',
1227
+ });
1228
+ }
1229
+ const weaponTotals = new Map();
1230
+ const classTotals = new Map();
1231
+ let landing = 0;
1232
+ let landingCount = 0;
1233
+ let matchCount = 0;
1234
+ for (const match of matches) {
1235
+ const stats = match.players[accountId];
1236
+ if (!stats)
1237
+ continue;
1238
+ matchCount += 1;
1239
+ for (const [weapon, damage] of Object.entries(stats.weaponDamage)) {
1240
+ weaponTotals.set(weapon, (weaponTotals.get(weapon) ?? 0) + damage);
1241
+ const weaponClass = weaponClassOf(weapon);
1242
+ classTotals.set(weaponClass, (classTotals.get(weaponClass) ?? 0) + damage);
1243
+ }
1244
+ if (stats.landingSpread !== null) {
1245
+ landing += stats.landingSpread;
1246
+ landingCount += 1;
1247
+ }
1248
+ }
1249
+ const weaponSum = [...weaponTotals.values()].reduce((sum, value) => sum + value, 0);
1250
+ const lead = traits
1251
+ .slice(0, 3)
1252
+ .map((trait) => `${trait.tier === 'lean' ? '倾向于' : ''}${trait.text}`)
1253
+ .join(';');
1254
+ const nameOfArchetype = (entry) => entry.tier === 'slight'
1255
+ ? `偏${entry.label}`
1256
+ : entry.tier === 'lean'
1257
+ ? `${entry.label}(倾向)`
1258
+ : entry.label;
1259
+ const archetypeText = archetypes.map(nameOfArchetype).join(' + ');
1260
+ const blurb = archetypes[0].id === 'balanced'
1261
+ ? '各项都接近队友,没有明显的短板或特长'
1262
+ : ARCHETYPES.find((item) => item.id === archetypes[0].id).blurb;
1263
+ return {
1264
+ accountId,
1265
+ name: input.names.get(accountId) ?? '未知',
1266
+ matches: matchCount,
1267
+ minutes: scalar(base.perPlayer.get(accountId), 'minutes'),
1268
+ axes,
1269
+ traits,
1270
+ archetypes,
1271
+ classShares: [...classTotals.entries()]
1272
+ .map(([weaponClass, damage]) => ({
1273
+ weaponClass,
1274
+ share: weaponSum > 0 ? damage / weaponSum : 0,
1275
+ }))
1276
+ .sort((left, right) => right.share - left.share)
1277
+ .slice(0, 4),
1278
+ weapons: [...weaponTotals.entries()]
1279
+ .map(([weapon, damage]) => ({ weapon, share: weaponSum > 0 ? damage / weaponSum : 0 }))
1280
+ .sort((left, right) => right.share - left.share || left.weapon.localeCompare(right.weapon))
1281
+ .slice(0, 3),
1282
+ landingSpread: landingCount ? landing / landingCount : null,
1283
+ summary: `${archetypeText}:${blurb}${lead ? `;${lead}` : ''}。`,
1284
+ };
1285
+ })
1286
+ .sort((left, right) => right.minutes - left.minutes || left.name.localeCompare(right.name));
1287
+ const combat = players
1288
+ .map((player) => ({ player, reading: player.axes.find((axis) => axis.key === composite.key) }))
1289
+ .sort((left, right) => (right.reading.ratio ?? -Infinity) - (left.reading.ratio ?? -Infinity))
1290
+ .map(({ player, reading }, index) => ({
1291
+ accountId: player.accountId,
1292
+ name: player.name,
1293
+ rank: index + 1,
1294
+ ratio: reading.ratio,
1295
+ low: reading.low,
1296
+ high: reading.high,
1297
+ confidence: reading.confidence,
1298
+ tier: reading.tier,
1299
+ components: (composite.composite ?? []).map((key) => {
1300
+ const component = player.axes.find((axis) => axis.key === key);
1301
+ return { key, label: component.label, ratio: component.ratio, tier: component.tier };
1302
+ }),
1303
+ }));
1304
+ const leader = combat[0];
1305
+ const trailer = combat.at(-1);
1306
+ const combatVerdict = !leader || leader.ratio === null
1307
+ ? '样本不足,暂时无法比较战斗力。'
1308
+ : (leader.tier === 'stable' || leader.tier === 'lean') && leader.ratio > 1
1309
+ ? `综合战斗力最强:${leader.name}(×${leader.ratio.toFixed(2)},${leader.tier === 'stable' ? '稳定领先' : `领先的把握约 ${leader.confidence === null ? '—' : (leader.confidence * 100).toFixed(0)}%`})。`
1310
+ : `综合战斗力没有显著差距:最高 ${leader.name} ×${leader.ratio.toFixed(2)},最低 ${trailer?.name ?? ''} ×${(trailer?.ratio ?? 1).toFixed(2)},差异都在抽样误差内;差别主要体现在下面的单项上。`;
1311
+ const combatHighlights = combat
1312
+ .flatMap((entry) => entry.components
1313
+ .filter((component) => (component.tier === 'stable' || component.tier === 'lean') && component.ratio !== null)
1314
+ .map((component) => ({
1315
+ name: entry.name,
1316
+ label: component.label,
1317
+ ratio: component.ratio,
1318
+ tier: component.tier,
1319
+ confidence: players
1320
+ .find((player) => player.accountId === entry.accountId)
1321
+ ?.axes.find((axis) => axis.key === component.key)?.confidence ?? null,
1322
+ })))
1323
+ .sort((left, right) => (left.tier === right.tier ? 0 : left.tier === 'stable' ? -1 : 1) ||
1324
+ Math.abs(Math.log2(right.ratio)) - Math.abs(Math.log2(left.ratio)))
1325
+ .slice(0, 8);
1326
+ const funnel = players.map((player) => {
1327
+ const vector = base.perPlayer.get(player.accountId);
1328
+ const { shots, hits, hitDamage } = mixTotals(vector);
1329
+ const idle = scalar(vector, 'idleShots');
1330
+ const finishing = scalar(vector, 'finishingShots');
1331
+ const minutes = scalar(vector, 'minutes');
1332
+ const knocks = fight(vector, 'knocksDealt');
1333
+ const dealt = fight(vector, 'chainsDealt');
1334
+ const fights = fight(vector, 'fights');
1335
+ return {
1336
+ accountId: player.accountId,
1337
+ name: player.name,
1338
+ fights,
1339
+ minutes,
1340
+ shots,
1341
+ hits,
1342
+ hitRate: shots > 0 ? hits / shots : null,
1343
+ firstShotRate: scalar(vector, 'firstShots') > 0
1344
+ ? scalar(vector, 'firstHits') / scalar(vector, 'firstShots')
1345
+ : null,
1346
+ followShotRate: scalar(vector, 'followShots') > 0
1347
+ ? scalar(vector, 'followHits') / scalar(vector, 'followShots')
1348
+ : null,
1349
+ burstShare: shots > 0 ? scalar(vector, 'followShots') / shots : null,
1350
+ idleShare: shots + idle > 0 ? idle / (shots + idle) : null,
1351
+ finishingShare: shots + idle + finishing > 0 ? finishing / (shots + idle + finishing) : null,
1352
+ hitDamage,
1353
+ damagePerShot: shots > 0 ? hitDamage / shots : null,
1354
+ damagePerMinute: minutes > 0
1355
+ ? (scalar(vector, 'gunDamage') + scalar(vector, 'explosiveDamage')) / minutes
1356
+ : null,
1357
+ knocks,
1358
+ hitsPerKnock: knocks > 0 ? hits / knocks : null,
1359
+ kills: fight(vector, 'kills'),
1360
+ knockFights: fight(vector, 'fightsWithKnock'),
1361
+ knockRate: fights > 0 ? fight(vector, 'fightsWithKnock') / fights : null,
1362
+ chainsDealt: dealt,
1363
+ chainsFinished: fight(vector, 'chainsFinished'),
1364
+ conversion: dealt > 0 ? fight(vector, 'chainsFinished') / dealt : null,
1365
+ };
1366
+ });
1367
+ const spreads = [];
1368
+ for (const axis of STYLE_AXES) {
1369
+ if (axis.composite)
1370
+ continue;
1371
+ const entries = players
1372
+ .map((player) => ({ player, reading: player.axes.find((item) => item.key === axis.key) }))
1373
+ .filter((item) => item.reading.value !== null);
1374
+ if (entries.length < 3)
1375
+ continue;
1376
+ const ordered = [...entries].sort((left, right) => left.reading.value - right.reading.value);
1377
+ const lowest = ordered[0];
1378
+ const highest = ordered.at(-1);
1379
+ if (!(lowest.reading.value > 0))
1380
+ continue;
1381
+ const supported = [lowest, highest].some((item) => item.reading.tier === 'stable' || item.reading.tier === 'lean');
1382
+ spreads.push({
1383
+ key: axis.key,
1384
+ group: axis.group,
1385
+ label: axis.label,
1386
+ highest: {
1387
+ name: highest.player.name,
1388
+ value: highest.reading.value,
1389
+ ratio: highest.reading.ratio,
1390
+ tier: highest.reading.tier,
1391
+ },
1392
+ lowest: {
1393
+ name: lowest.player.name,
1394
+ value: lowest.reading.value,
1395
+ ratio: lowest.reading.ratio,
1396
+ tier: lowest.reading.tier,
1397
+ },
1398
+ spread: highest.reading.value / lowest.reading.value,
1399
+ supported,
1400
+ });
1401
+ }
1402
+ spreads.sort((left, right) => Number(right.supported) - Number(left.supported) || right.spread - left.spread);
1403
+ const vector = (player) => player.axes.map((axis) => axis.ratio !== null && (axis.tier === 'stable' || axis.tier === 'lean')
1404
+ ? clip(Math.log2(axis.ratio))
1405
+ : 0);
1406
+ const overlaps = [];
1407
+ for (let left = 0; left < players.length; left += 1) {
1408
+ for (let right = left + 1; right < players.length; right += 1) {
1409
+ const a = vector(players[left]);
1410
+ const b = vector(players[right]);
1411
+ const norm = Math.hypot(...a) * Math.hypot(...b);
1412
+ if (norm === 0)
1413
+ continue;
1414
+ const similarity = a.reduce((sum, value, index) => sum + value * b[index], 0) / norm;
1415
+ const shared = players[left].traits.filter((trait) => players[right].traits.some((other) => other.key === trait.key && other.ratio >= 1 === trait.ratio >= 1)).map((trait) => trait.text);
1416
+ if (similarity >= 0.6 && shared.length)
1417
+ overlaps.push({
1418
+ accountIds: [players[left].accountId, players[right].accountId],
1419
+ names: [players[left].name, players[right].name],
1420
+ similarity,
1421
+ sharedTraits: shared,
1422
+ });
1423
+ }
1424
+ }
1425
+ overlaps.sort((left, right) => right.similarity - left.similarity);
1426
+ const gaps = [];
1427
+ const anyHigh = (key) => players.some((player) => {
1428
+ const reading = player.axes.find((axis) => axis.key === key);
1429
+ return (reading?.direction === 'high' && (reading.tier === 'stable' || reading.tier === 'lean'));
1430
+ });
1431
+ if (players.length >= 3 && matches.length >= 20) {
1432
+ const withoutTraits = players.filter((player) => player.traits.length === 0).length;
1433
+ if (withoutTraits >= players.length - 1) {
1434
+ gaps.push(`小队打法高度同质:${players.length} 人中有 ${withoutTraits} 人与队友相比没有任何稳定或倾向性的差异,还没有形成明确的分工。`);
1435
+ }
1436
+ if (!anyHigh('range') && !anyHigh('farShare'))
1437
+ gaps.push('没有人偏向远距离输出,小队缺少固定的远程火力点。');
1438
+ if (!anyHigh('revive'))
1439
+ gaps.push('没有人在救援上明显突出,倒地后的补位可能依赖随机的最近队友。');
1440
+ if (!anyHigh('utility'))
1441
+ gaps.push('没有人明显偏爱投掷物,烟雾和手雷的战术价值没有被系统利用。');
1442
+ }
1443
+ return {
1444
+ schemaVersion: STYLE_SCHEMA_VERSION,
1445
+ ruleVersion: STYLE_RULE_VERSION,
1446
+ matches: matches.length,
1447
+ players,
1448
+ combatVerdict,
1449
+ combatHighlights,
1450
+ combat,
1451
+ funnel,
1452
+ spreads,
1453
+ overlaps: overlaps.slice(0, 3),
1454
+ gaps,
1455
+ notes: [
1456
+ `每个维度是「个人值 ÷ 队友合计值」(不含自己),×1.00 即与队友持平。分三档:稳定 = 95% 重抽样区间不含 1 且比值 ≥${STYLE_NOTABLE_RATIO} 或 ≤${(1 / STYLE_NOTABLE_RATIO).toFixed(2)};倾向 = 方向在 ≥${STYLE_LEAN_CONFIDENCE * 100}% 的重抽样中一致但区间跨过 1,只能说「更可能」;其余视为持平。`,
1457
+ `战斗力指数 = 每分钟伤害、命中率、交战击倒率、击倒转化率、爆头率五项比值的几何平均,区间由同一批重抽样得到。类型标签由固定加权规则给出:稳定且得分高才直接命名,较弱时写「偏…」,全部接近队友时记为「均衡全能型」。`,
1458
+ ],
1459
+ };
1460
+ }
1461
+ //# sourceMappingURL=style.js.map