@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
@@ -0,0 +1,636 @@
1
+ import { parseTelemetryPayload } from '@rine-kit/pubg-domain';
2
+ import { normalizeWeaponName } from './analytics.js';
3
+ import { buildDowns, buildRotation, buildTurningPoints, } from './match-insight.js';
4
+ export const MATCH_STORY_SCHEMA_VERSION = 1;
5
+ /**
6
+ * A fight stays open while the squad keeps making contact with one of its opposing teams at most this far apart, and
7
+ * a contact with a team that is not yet in any open fight joins one only when it comes within STORY_FIGHT_JOIN_SECONDS
8
+ * of that fight's latest contact (a third party arriving mid-fight); otherwise it starts a separate fight. Grouping by
9
+ * time alone chained back-to-back fights against different teams into one fight of several minutes.
10
+ */
11
+ export const STORY_FIGHT_GAP_SECONDS = 35;
12
+ export const STORY_FIGHT_JOIN_SECONDS = 12;
13
+ /** Route samples closer together than this are dropped to keep the payload small. */
14
+ export const STORY_ROUTE_STEP_SECONDS = 10;
15
+ /** Opposing players are sampled less often than the squad to keep the payload small. */
16
+ export const STORY_ENEMY_STEP_SECONDS = 20;
17
+ const CIRCLE_STEP_SECONDS = 15;
18
+ const DAMAGE_BIN_SECONDS = 60;
19
+ function obj(value) {
20
+ return value && typeof value === 'object' && !Array.isArray(value)
21
+ ? value
22
+ : {};
23
+ }
24
+ function num(value) {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
26
+ }
27
+ function str(value) {
28
+ return typeof value === 'string' && value ? value : null;
29
+ }
30
+ const round1 = (value) => Math.round(value * 10) / 10;
31
+ function secondsOf(event, startMs) {
32
+ const iso = typeof event._D === 'string' ? Date.parse(event._D) : NaN;
33
+ return Number.isFinite(iso) ? Math.max(0, (iso - startMs) / 1000) : null;
34
+ }
35
+ /**
36
+ * Turns one raw telemetry payload into a readable story of the squad's match: who went down and when, every
37
+ * fight in plain terms, the route on the map against the circle, and how fast damage was dealt. Times are seconds
38
+ * from the match start (LogMatchStart).
39
+ */
40
+ export function analyzeMatchStory(payload, trackedIds) {
41
+ const events = parseTelemetryPayload(payload).map(obj);
42
+ const startEvent = events.find((event) => event._T === 'LogMatchStart');
43
+ const firstTime = events
44
+ .map((event) => (typeof event._D === 'string' ? Date.parse(event._D) : NaN))
45
+ .find((value) => Number.isFinite(value));
46
+ const startMs = typeof startEvent?._D === 'string' ? Date.parse(startEvent._D) : (firstTime ?? 0);
47
+ const trackedTeams = new Set();
48
+ const nameOf = new Map();
49
+ const teamOf = new Map();
50
+ for (const event of events) {
51
+ for (const key of [
52
+ 'character',
53
+ 'attacker',
54
+ 'victim',
55
+ 'killer',
56
+ 'finisher',
57
+ 'reviver',
58
+ 'dBNOMaker',
59
+ ]) {
60
+ const character = obj(event[key]);
61
+ const id = str(character.accountId);
62
+ const team = character.teamId === undefined || character.teamId === null
63
+ ? null
64
+ : String(character.teamId);
65
+ if (id && team !== null)
66
+ teamOf.set(id, team);
67
+ if (id && trackedIds.has(id) && team !== null)
68
+ trackedTeams.add(team);
69
+ if (id && trackedIds.has(id))
70
+ nameOf.set(id, str(character.name) ?? id);
71
+ }
72
+ }
73
+ const isEnemy = (character) => {
74
+ const team = obj(character).teamId;
75
+ return team !== undefined && team !== null && !trackedTeams.has(String(team));
76
+ };
77
+ const trackedId = (character) => {
78
+ const id = str(obj(character).accountId);
79
+ return id && trackedIds.has(id) ? id : null;
80
+ };
81
+ const story = {
82
+ schemaVersion: MATCH_STORY_SCHEMA_VERSION,
83
+ mapName: str(startEvent?.mapName),
84
+ durationSeconds: 0,
85
+ startTeams: null,
86
+ startPlayers: null,
87
+ events: [],
88
+ fights: [],
89
+ circle: [],
90
+ phaseStarts: [],
91
+ players: [],
92
+ enemies: [],
93
+ downs: [],
94
+ turningPoints: [],
95
+ rotation: {
96
+ epochs: [],
97
+ blueSeconds: 0,
98
+ blueDamage: 0,
99
+ distance: 0,
100
+ vehicleShare: null,
101
+ landingToFight: null,
102
+ lateEntries: 0,
103
+ circlesChecked: 0,
104
+ },
105
+ squadOutAt: null,
106
+ teamsAtEnd: null,
107
+ };
108
+ const life = [];
109
+ const knockedBy = new Map();
110
+ const dead = new Set();
111
+ const routes = new Map();
112
+ const lastRouteT = new Map();
113
+ const lastEnemyT = new Map();
114
+ const enemyRoutes = new Map();
115
+ const enemyDied = new Map();
116
+ const enemyLanded = new Map();
117
+ const landings = new Map();
118
+ const damageBins = new Map();
119
+ const contacts = [];
120
+ const blueDamage = [];
121
+ let lastCircleT = -Infinity;
122
+ let maxT = 0;
123
+ for (const event of events) {
124
+ const type = event._T;
125
+ const t = secondsOf(event, startMs);
126
+ if (t === null)
127
+ continue;
128
+ const elapsed = num(obj(event.gameState).elapsedTime);
129
+ const isGame = num(obj(event.common).isGame);
130
+ const phase = isGame !== null && isGame >= 1 ? Math.floor(isGame) : null;
131
+ maxT = Math.max(maxT, t);
132
+ if (type === 'LogGameStatePeriodic') {
133
+ const state = obj(event.gameState);
134
+ const time = elapsed ?? t;
135
+ if (story.startTeams === null) {
136
+ story.startTeams = num(state.numStartTeams);
137
+ story.startPlayers = num(state.numStartPlayers);
138
+ }
139
+ if (time - lastCircleT < CIRCLE_STEP_SECONDS)
140
+ continue;
141
+ lastCircleT = time;
142
+ const zone = obj(state.safetyZonePosition);
143
+ const warn = obj(state.poisonGasWarningPosition);
144
+ const zoneRadius = num(state.safetyZoneRadius);
145
+ const warnRadius = num(state.poisonGasWarningRadius);
146
+ story.circle.push({
147
+ t: Math.round(time),
148
+ phase,
149
+ aliveTeams: num(state.numAliveTeams),
150
+ alivePlayers: num(state.numAlivePlayers),
151
+ zone: num(zone.x) !== null && num(zone.y) !== null && zoneRadius
152
+ ? {
153
+ x: round1(num(zone.x) / 100),
154
+ y: round1(num(zone.y) / 100),
155
+ r: round1(zoneRadius / 100),
156
+ }
157
+ : null,
158
+ warn: num(warn.x) !== null && num(warn.y) !== null && warnRadius
159
+ ? {
160
+ x: round1(num(warn.x) / 100),
161
+ y: round1(num(warn.y) / 100),
162
+ r: round1(warnRadius / 100),
163
+ }
164
+ : null,
165
+ });
166
+ continue;
167
+ }
168
+ if (type === 'LogParachuteLanding') {
169
+ const enemyLanding = str(obj(event.character).accountId);
170
+ if (enemyLanding && isEnemy(event.character))
171
+ enemyLanded.set(enemyLanding, t);
172
+ const id = trackedId(event.character);
173
+ const location = obj(obj(event.character).location);
174
+ if (id && num(location.x) !== null && num(location.y) !== null && !landings.has(id))
175
+ landings.set(id, {
176
+ t,
177
+ x: round1(num(location.x) / 100),
178
+ y: round1(num(location.y) / 100),
179
+ });
180
+ continue;
181
+ }
182
+ if (type === 'LogPlayerPosition') {
183
+ const id = trackedId(event.character);
184
+ const elapsedTime = num(event.elapsedTime);
185
+ const location = obj(obj(event.character).location);
186
+ if (!id &&
187
+ elapsedTime !== null &&
188
+ elapsedTime > 0 &&
189
+ isEnemy(event.character) &&
190
+ obj(event.character).type !== 'ai' &&
191
+ num(location.x) !== null &&
192
+ num(location.y) !== null) {
193
+ const enemyId = str(obj(event.character).accountId);
194
+ if (enemyId &&
195
+ elapsedTime - (lastEnemyT.get(enemyId) ?? -Infinity) >= STORY_ENEMY_STEP_SECONDS) {
196
+ lastEnemyT.set(enemyId, elapsedTime);
197
+ const list = enemyRoutes.get(enemyId) ?? [];
198
+ list.push([
199
+ Math.round(elapsedTime),
200
+ Math.round(num(location.x) / 100),
201
+ Math.round(num(location.y) / 100),
202
+ ]);
203
+ enemyRoutes.set(enemyId, list);
204
+ }
205
+ continue;
206
+ }
207
+ if (!id ||
208
+ elapsedTime === null ||
209
+ elapsedTime <= 0 ||
210
+ num(location.x) === null ||
211
+ num(location.y) === null)
212
+ continue;
213
+ if (elapsedTime - (lastRouteT.get(id) ?? -Infinity) < STORY_ROUTE_STEP_SECONDS)
214
+ continue;
215
+ lastRouteT.set(id, elapsedTime);
216
+ const list = routes.get(id) ?? [];
217
+ list.push({
218
+ t: Math.round(elapsedTime),
219
+ x: round1(num(location.x) / 100),
220
+ y: round1(num(location.y) / 100),
221
+ vehicle: obj(event.character).isInVehicle === true,
222
+ down: obj(event.character).isDBNO === true,
223
+ ...(obj(event.character).isInBlueZone === true ? { blue: true } : {}),
224
+ });
225
+ routes.set(id, list);
226
+ continue;
227
+ }
228
+ if (type === 'LogPlayerTakeDamage') {
229
+ const category = str(event.damageTypeCategory) ?? '';
230
+ const damage = num(event.damage);
231
+ if (category === 'Damage_BlueZone' && damage !== null && damage > 0) {
232
+ const sufferer = trackedId(event.victim);
233
+ if (sufferer)
234
+ blueDamage.push({ t, member: sufferer, amount: damage });
235
+ continue;
236
+ }
237
+ if (damage === null ||
238
+ damage <= 0 ||
239
+ !(category === 'Damage_Gun' ||
240
+ category === 'Damage_DBNO' ||
241
+ category.startsWith('Damage_Explosion') ||
242
+ category === 'Damage_Molotov'))
243
+ continue;
244
+ const attackerId = trackedId(event.attacker);
245
+ const victimId = trackedId(event.victim);
246
+ if (attackerId && isEnemy(event.victim)) {
247
+ contacts.push({
248
+ t,
249
+ kind: 'dealt',
250
+ member: attackerId,
251
+ enemyTeam: String(obj(event.victim).teamId),
252
+ amount: damage,
253
+ victim: str(obj(event.victim).accountId),
254
+ });
255
+ const bins = damageBins.get(attackerId) ?? [];
256
+ const bin = Math.floor(t / DAMAGE_BIN_SECONDS);
257
+ while (bins.length <= bin)
258
+ bins.push(0);
259
+ bins[bin] += damage;
260
+ damageBins.set(attackerId, bins);
261
+ }
262
+ else if (victimId && isEnemy(event.attacker)) {
263
+ contacts.push({
264
+ t,
265
+ kind: 'taken',
266
+ member: victimId,
267
+ enemyTeam: String(obj(event.attacker).teamId),
268
+ amount: damage,
269
+ victim: victimId,
270
+ weapon: str(event.damageCauserName)
271
+ ? normalizeWeaponName(String(event.damageCauserName))
272
+ : null,
273
+ });
274
+ }
275
+ continue;
276
+ }
277
+ if (type === 'LogPlayerMakeGroggy') {
278
+ const attackerId = trackedId(event.attacker);
279
+ const victimId = trackedId(event.victim);
280
+ const weapon = str(event.damageCauserName)
281
+ ? normalizeWeaponName(String(event.damageCauserName))
282
+ : null;
283
+ const meters = num(event.distance) !== null ? Math.round(num(event.distance) / 100) : null;
284
+ const victimAccount = str(obj(event.victim).accountId);
285
+ if (attackerId && isEnemy(event.victim)) {
286
+ story.events.push({
287
+ t,
288
+ kind: 'knock',
289
+ member: attackerId,
290
+ other: null,
291
+ weapon,
292
+ meters,
293
+ byPlayer: true,
294
+ afterKnock: false,
295
+ });
296
+ contacts.push({
297
+ t,
298
+ kind: 'knock',
299
+ member: attackerId,
300
+ enemyTeam: String(obj(event.victim).teamId),
301
+ amount: 0,
302
+ victim: victimAccount,
303
+ });
304
+ if (victimAccount)
305
+ knockedBy.set(victimAccount, attackerId);
306
+ }
307
+ else if (victimId) {
308
+ const byPlayer = isEnemy(event.attacker);
309
+ story.events.push({
310
+ t,
311
+ kind: 'downed',
312
+ member: victimId,
313
+ other: null,
314
+ weapon,
315
+ meters,
316
+ byPlayer,
317
+ afterKnock: false,
318
+ });
319
+ life.push({ t, member: victimId, type: 'down' });
320
+ if (byPlayer)
321
+ contacts.push({
322
+ t,
323
+ kind: 'downed',
324
+ member: victimId,
325
+ enemyTeam: String(obj(event.attacker).teamId),
326
+ amount: 0,
327
+ victim: victimId,
328
+ });
329
+ }
330
+ continue;
331
+ }
332
+ if (type === 'LogPlayerRevive') {
333
+ const reviver = trackedId(event.reviver);
334
+ const victim = trackedId(event.victim);
335
+ if (reviver && victim) {
336
+ story.events.push({
337
+ t,
338
+ kind: 'revive',
339
+ member: reviver,
340
+ other: victim,
341
+ weapon: null,
342
+ meters: null,
343
+ byPlayer: false,
344
+ afterKnock: false,
345
+ });
346
+ life.push({ t, member: victim, type: 'revived' });
347
+ contacts.push({ t, kind: 'revive', member: reviver, enemyTeam: null, amount: 0, victim });
348
+ }
349
+ continue;
350
+ }
351
+ if (type === 'LogPlayerKillV2' || type === 'LogPlayerKill') {
352
+ const victimAccount = str(obj(event.victim).accountId);
353
+ const victimId = trackedId(event.victim);
354
+ const killerRef = trackedId(event.finisher)
355
+ ? event.finisher
356
+ : trackedId(event.killer)
357
+ ? event.killer
358
+ : (event.finisher ?? event.killer);
359
+ const killerId = trackedId(killerRef);
360
+ const info = obj(event.finishDamageInfo);
361
+ const weapon = str(info.damageCauserName)
362
+ ? normalizeWeaponName(String(info.damageCauserName))
363
+ : null;
364
+ const meters = num(info.distance) !== null && num(info.distance) >= 0
365
+ ? Math.round(num(info.distance) / 100)
366
+ : null;
367
+ const afterKnock = obj(event.dBNOMaker).accountId !== undefined && event.dBNOMaker !== null;
368
+ if (victimAccount && isEnemy(event.victim) && !enemyDied.has(victimAccount))
369
+ enemyDied.set(victimAccount, t);
370
+ if (killerId && isEnemy(event.victim)) {
371
+ story.events.push({
372
+ t,
373
+ kind: 'kill',
374
+ member: killerId,
375
+ other: null,
376
+ weapon,
377
+ meters,
378
+ byPlayer: true,
379
+ afterKnock,
380
+ });
381
+ contacts.push({
382
+ t,
383
+ kind: 'kill',
384
+ member: killerId,
385
+ enemyTeam: String(obj(event.victim).teamId),
386
+ amount: 0,
387
+ victim: victimAccount,
388
+ });
389
+ }
390
+ else if (victimId && !dead.has(victimId)) {
391
+ dead.add(victimId);
392
+ const byPlayer = isEnemy(killerRef);
393
+ story.events.push({
394
+ t,
395
+ kind: 'death',
396
+ member: victimId,
397
+ other: null,
398
+ weapon: byPlayer ? weapon : null,
399
+ meters: byPlayer ? meters : null,
400
+ byPlayer,
401
+ afterKnock,
402
+ });
403
+ life.push({ t, member: victimId, type: 'dead' });
404
+ }
405
+ continue;
406
+ }
407
+ }
408
+ story.durationSeconds = Math.round(Math.max(maxT, story.circle.at(-1)?.t ?? 0));
409
+ story.events.sort((left, right) => left.t - right.t);
410
+ story.circle.sort((left, right) => left.t - right.t);
411
+ const seenPhase = new Set();
412
+ for (const point of story.circle) {
413
+ if (point.phase !== null && !seenPhase.has(point.phase)) {
414
+ seenPhase.add(point.phase);
415
+ story.phaseStarts.push({ phase: point.phase, t: point.t });
416
+ }
417
+ }
418
+ const stateAt = (t) => {
419
+ let found = null;
420
+ for (const point of story.circle) {
421
+ if (point.t > t)
422
+ break;
423
+ found = point;
424
+ }
425
+ return found;
426
+ };
427
+ // Lifelines: alive from landing, down on a knock, back alive on a revive, dead on the kill.
428
+ const ids = [...trackedIds].filter((id) => landings.has(id) || routes.has(id) || life.some((item) => item.member === id));
429
+ for (const id of ids) {
430
+ const landing = landings.get(id) ?? null;
431
+ // Positions before the landing are the plane's flight path, not the squad's route.
432
+ const route = (routes.get(id) ?? []).filter((point) => !landing || point.t >= landing.t);
433
+ const begin = landing?.t ?? route[0]?.t ?? 0;
434
+ const own = life.filter((item) => item.member === id).sort((left, right) => left.t - right.t);
435
+ const segments = [];
436
+ let state = 'alive';
437
+ let from = begin;
438
+ let diedAt = null;
439
+ for (const item of own) {
440
+ const next = item.type === 'down' ? 'down' : item.type === 'revived' ? 'alive' : 'dead';
441
+ if (item.type === 'dead' && state === 'dead')
442
+ continue;
443
+ if (item.t > from)
444
+ segments.push({ from, to: item.t, state });
445
+ from = Math.max(from, item.t);
446
+ state = next;
447
+ if (next === 'dead') {
448
+ diedAt = item.t;
449
+ break;
450
+ }
451
+ }
452
+ if (state !== 'dead')
453
+ segments.push({ from, to: Math.max(from, story.durationSeconds), state });
454
+ story.players.push({
455
+ member: id,
456
+ landing,
457
+ lifeline: segments,
458
+ route,
459
+ diedAt,
460
+ damagePerMinute: damageBins.get(id) ?? [],
461
+ });
462
+ }
463
+ const deaths = story.players.map((player) => player.diedAt);
464
+ story.squadOutAt =
465
+ ids.length > 0 && deaths.every((value) => value !== null)
466
+ ? Math.max(...deaths)
467
+ : null;
468
+ story.teamsAtEnd = stateAt(story.squadOutAt ?? story.durationSeconds)?.aliveTeams ?? null;
469
+ // Fights: contacts with the squad grouped by opposing team and time; an enemy takedown is a knock, or a kill that had no knock.
470
+ contacts.sort((left, right) => left.t - right.t);
471
+ const buildFight = (group) => {
472
+ if (!group.length)
473
+ return;
474
+ const start = group[0].t;
475
+ const end = group.at(-1).t;
476
+ const enemyDown = new Set();
477
+ const ourDown = new Set();
478
+ const kills = new Set();
479
+ let revives = 0;
480
+ let dealt = 0;
481
+ let taken = 0;
482
+ const perMember = new Map();
483
+ const slot = (member) => perMember.get(member) ??
484
+ (perMember.set(member, { damage: 0, knocks: 0, downed: 0 }), perMember.get(member));
485
+ for (const item of group) {
486
+ if (item.kind === 'dealt') {
487
+ dealt += item.amount;
488
+ slot(item.member).damage += item.amount;
489
+ }
490
+ else if (item.kind === 'taken') {
491
+ taken += item.amount;
492
+ slot(item.member);
493
+ }
494
+ else if (item.kind === 'knock' && item.victim) {
495
+ enemyDown.add(item.victim);
496
+ slot(item.member).knocks += 1;
497
+ }
498
+ else if (item.kind === 'kill' && item.victim) {
499
+ enemyDown.add(item.victim);
500
+ kills.add(item.victim);
501
+ }
502
+ else if (item.kind === 'downed') {
503
+ ourDown.add(item.member);
504
+ slot(item.member).downed += 1;
505
+ }
506
+ else if (item.kind === 'revive')
507
+ revives += 1;
508
+ }
509
+ const teamsInFight = [
510
+ ...new Set(group.map((item) => item.enemyTeam).filter((team) => team !== null)),
511
+ ];
512
+ const enemies = teamsInFight
513
+ .map((team) => {
514
+ const weapons = new Map();
515
+ let damageToUs = 0;
516
+ let damageFromUs = 0;
517
+ const downed = new Set();
518
+ for (const item of group) {
519
+ if (item.enemyTeam !== team)
520
+ continue;
521
+ if (item.kind === 'taken') {
522
+ damageToUs += item.amount;
523
+ if (item.weapon)
524
+ weapons.set(item.weapon, (weapons.get(item.weapon) ?? 0) + item.amount);
525
+ }
526
+ else if (item.kind === 'dealt')
527
+ damageFromUs += item.amount;
528
+ else if ((item.kind === 'knock' || item.kind === 'kill') && item.victim)
529
+ downed.add(item.victim);
530
+ }
531
+ return {
532
+ team,
533
+ size: new Set([...teamOf.entries()].filter(([, value]) => value === team).map(([id]) => id)).size,
534
+ downed: downed.size,
535
+ damageToUs: Math.round(damageToUs),
536
+ damageFromUs: Math.round(damageFromUs),
537
+ weapons: [...weapons.entries()]
538
+ .sort((left, right) => right[1] - left[1])
539
+ .slice(0, 3)
540
+ .map(([weapon]) => weapon),
541
+ };
542
+ })
543
+ .sort((left, right) => right.damageToUs + right.damageFromUs - left.damageToUs - left.damageFromUs);
544
+ const first = group.find((item) => item.kind === 'dealt' || item.kind === 'taken');
545
+ const state = stateAt(start);
546
+ const enemyDowns = enemyDown.size;
547
+ const ourDowns = ourDown.size;
548
+ story.fights.push({
549
+ index: story.fights.length + 1,
550
+ start: Math.round(start),
551
+ end: Math.round(end),
552
+ phase: state?.phase ?? null,
553
+ aliveTeams: state?.aliveTeams ?? null,
554
+ enemyTeams: new Set(group.map((item) => item.enemyTeam).filter((team) => team !== null)).size,
555
+ outcome: enemyDowns === 0 && ourDowns === 0
556
+ ? 'skirmish'
557
+ : enemyDowns > ourDowns
558
+ ? 'win'
559
+ : enemyDowns < ourDowns
560
+ ? 'lose'
561
+ : 'draw',
562
+ initiator: !first ? 'unknown' : first.kind === 'dealt' ? 'us' : 'them',
563
+ enemyDowns,
564
+ ourDowns,
565
+ ourKills: kills.size,
566
+ revives,
567
+ damageDealt: Math.round(dealt),
568
+ damageTaken: Math.round(taken),
569
+ members: [...perMember.entries()]
570
+ .map(([member, value]) => ({
571
+ member,
572
+ damage: Math.round(value.damage),
573
+ knocks: value.knocks,
574
+ downed: value.downed,
575
+ }))
576
+ .sort((left, right) => right.damage - left.damage),
577
+ enemies,
578
+ });
579
+ };
580
+ const open = [];
581
+ const finished = [];
582
+ for (const item of contacts) {
583
+ for (let index = open.length - 1; index >= 0; index -= 1) {
584
+ if (item.t - open[index].last > STORY_FIGHT_GAP_SECONDS)
585
+ finished.push(open.splice(index, 1)[0].contacts);
586
+ }
587
+ let target;
588
+ if (item.enemyTeam !== null)
589
+ target =
590
+ open.find((fight) => fight.teams.has(item.enemyTeam)) ??
591
+ open.find((fight) => item.t - fight.last <= STORY_FIGHT_JOIN_SECONDS);
592
+ else
593
+ target = [...open].sort((left, right) => right.last - left.last)[0]; // a revive belongs to the latest fight
594
+ if (!target) {
595
+ if (item.enemyTeam === null)
596
+ continue; // a revive with no fight around it
597
+ target = { contacts: [], teams: new Set(), last: item.t };
598
+ open.push(target);
599
+ }
600
+ target.contacts.push(item);
601
+ if (item.enemyTeam !== null)
602
+ target.teams.add(item.enemyTeam);
603
+ target.last = item.t;
604
+ }
605
+ for (const fight of open)
606
+ finished.push(fight.contacts);
607
+ finished.sort((left, right) => left[0].t - right[0].t);
608
+ for (const group of finished)
609
+ buildFight(group);
610
+ // Opposing players: like the squad, positions before the landing are the plane's flight path and are dropped.
611
+ for (const [id, allPoints] of enemyRoutes) {
612
+ const team = teamOf.get(id);
613
+ const landed = enemyLanded.get(id);
614
+ const route = allPoints.filter((point) => landed === undefined || point[0] >= landed);
615
+ if (team === undefined || route.length < 2)
616
+ continue;
617
+ story.enemies.push({ id, team, route, diedAt: enemyDied.get(id) ?? null });
618
+ }
619
+ story.enemies.sort((left, right) => left.team.localeCompare(right.team, 'en', { numeric: true }) ||
620
+ left.id.localeCompare(right.id));
621
+ story.downs = buildDowns(story, contacts, blueDamage);
622
+ story.turningPoints = buildTurningPoints(story);
623
+ story.rotation = buildRotation(story, blueDamage);
624
+ return story;
625
+ }
626
+ /** Display label for a fight result. */
627
+ export function fightOutcomeText(outcome) {
628
+ return outcome === 'win'
629
+ ? '胜'
630
+ : outcome === 'lose'
631
+ ? '负'
632
+ : outcome === 'draw'
633
+ ? '均势'
634
+ : '试探';
635
+ }
636
+ //# sourceMappingURL=match-story.js.map