@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,1249 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { normalizeTelemetryEvents, parseTelemetryPayload, combatEventKey, } from '@rine-kit/pubg-domain';
3
+ export const BATTLE_RECONSTRUCTION_SCHEMA_VERSION = 2;
4
+ export const BATTLE_RECONSTRUCTION_PARSER_VERSION = 'battle-reconstruction-v2';
5
+ export const BATTLE_CONTACT_GAP_SECONDS = 28;
6
+ export const BATTLE_KNOCK_OUTCOME_WINDOW_SECONDS = 120;
7
+ export const BATTLE_POST_KNOCK_RESPONSE_WINDOW_SECONDS = 30;
8
+ export const BATTLE_CONTEXT_BEFORE_SECONDS = 5;
9
+ export const BATTLE_CONTEXT_AFTER_SECONDS = 28;
10
+ export const BATTLE_POSITION_SAMPLE_INTERVAL_SECONDS = 15;
11
+ const COMBAT_CONTACT_TYPES = new Set(['DAMAGE', 'KNOCK', 'KILL']);
12
+ const TIMELINE_CONTEXT_TYPES = new Set(['ATTACK', 'POSITION']);
13
+ const BATTLE_DOMAIN_TYPES = new Set([
14
+ 'ATTACK',
15
+ 'DAMAGE',
16
+ 'KNOCK',
17
+ 'KILL',
18
+ 'REVIVE',
19
+ 'POSITION',
20
+ ]);
21
+ function objectValue(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value)
23
+ ? value
24
+ : {};
25
+ }
26
+ function numberValue(value) {
27
+ if (value === null || value === undefined || value === '')
28
+ return null;
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ function idDigest(value) {
33
+ return createHash('sha256').update(value).digest('hex').slice(0, 12);
34
+ }
35
+ export function battlePlayerRef(matchId, playerId) {
36
+ return `player-${idDigest(`${matchId}\u001f${playerId}`)}`;
37
+ }
38
+ export function battleTeamRef(matchId, teamId) {
39
+ return `team-${idDigest(`${matchId}\u001f${teamId}`)}`;
40
+ }
41
+ function vectorOf(value) {
42
+ const location = objectValue(value);
43
+ const nested = objectValue(location.location ?? location.position);
44
+ const source = Object.keys(nested).length ? nested : location;
45
+ const x = numberValue(source.x ?? source.X);
46
+ const y = numberValue(source.y ?? source.Y);
47
+ const z = numberValue(source.z ?? source.Z);
48
+ return x === null && y === null && z === null ? null : { x, y, z };
49
+ }
50
+ function zoneOf(state, positionKey, radiusKey) {
51
+ const radius = numberValue(state[radiusKey]);
52
+ return {
53
+ center: vectorOf(state[positionKey]),
54
+ radius: radius !== null && radius >= 0 ? radius : null,
55
+ };
56
+ }
57
+ function absoluteElapsed(raw, match) {
58
+ const createdAt = Date.parse(String(match.createdAt ?? ''));
59
+ const timestamp = numberValue(match.timestamp);
60
+ const start = Number.isFinite(createdAt)
61
+ ? createdAt
62
+ : timestamp !== null && timestamp > 100_000_000_000
63
+ ? timestamp
64
+ : timestamp !== null && timestamp > 1_000_000_000
65
+ ? timestamp * 1000
66
+ : Number.NaN;
67
+ if (!Number.isFinite(start))
68
+ return null;
69
+ for (const candidate of [raw._D, raw.timestamp, raw.createdAt, raw.time]) {
70
+ if (typeof candidate !== 'string' || /^\d{1,2}:\d{2}(?::\d{2})?$/u.test(candidate.trim()))
71
+ continue;
72
+ const timestamp = Date.parse(candidate);
73
+ if (!Number.isFinite(timestamp))
74
+ continue;
75
+ const delta = (timestamp - start) / 1000;
76
+ if (delta >= 0 && delta < 24 * 60 * 60)
77
+ return delta;
78
+ }
79
+ return null;
80
+ }
81
+ function normalizeCircle(rawValue, rawIndex, match) {
82
+ const raw = objectValue(rawValue);
83
+ const rawType = String(raw._T ?? raw.type ?? raw.eventType ?? '');
84
+ if (!rawType.toLowerCase().includes('gamestateperiodic'))
85
+ return null;
86
+ const state = objectValue(raw.gameState ?? raw.game_state);
87
+ const common = objectValue(raw.common);
88
+ const absolute = absoluteElapsed(raw, match);
89
+ const clockValue = [raw._D, raw.timestamp, raw.createdAt, raw.time].find((value) => typeof value === 'string' && /^\d{1,2}:\d{2}(?::\d{2})?$/u.test(value.trim()));
90
+ const clockParts = typeof clockValue === 'string' ? clockValue.split(':').map(Number) : [];
91
+ const clockSeconds = clockParts.length === 3
92
+ ? clockParts[0] * 3600 + clockParts[1] * 60 + clockParts[2]
93
+ : clockParts.length === 2
94
+ ? clockParts[0] * 60 + clockParts[1]
95
+ : null;
96
+ const elapsed = [state.elapsedTime, raw.elapsedTime, common.elapsedTime]
97
+ .map(numberValue)
98
+ .find((value) => value !== null && value >= 0 && value < 24 * 60 * 60);
99
+ const timeSeconds = absolute ?? (clockSeconds !== null && clockSeconds > 0 ? clockSeconds : (elapsed ?? null));
100
+ const timestampSource = absolute !== null
101
+ ? 'absolute'
102
+ : clockSeconds !== null && clockSeconds > 0
103
+ ? 'clock'
104
+ : elapsed !== undefined
105
+ ? 'elapsed'
106
+ : 'unknown';
107
+ const integer = (value) => {
108
+ const parsed = numberValue(value);
109
+ return parsed !== null && Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
110
+ };
111
+ const phaseValue = numberValue(common.isGame);
112
+ const phase = typeof common.isGame === 'boolean'
113
+ ? common.isGame
114
+ ? 'in_match'
115
+ : 'pre_match'
116
+ : phaseValue !== null && phaseValue >= 0 && phaseValue < 1
117
+ ? 'pre_match'
118
+ : phaseValue !== null && phaseValue >= 1
119
+ ? 'in_match'
120
+ : 'unknown';
121
+ const sourceEventId = `telemetry-${rawIndex + 1}`;
122
+ return {
123
+ id: `circle-${idDigest(`${match.matchId}\u001f${sourceEventId}`)}`,
124
+ sourceEventId,
125
+ timeSeconds,
126
+ timestampSource,
127
+ phase,
128
+ elapsedSeconds: elapsed ?? null,
129
+ alivePlayers: integer(state.numAlivePlayers),
130
+ aliveTeams: integer(state.numAliveTeams),
131
+ safetyZone: zoneOf(state, 'safetyZonePosition', 'safetyZoneRadius'),
132
+ poisonGasWarningZone: zoneOf(state, 'poisonGasWarningPosition', 'poisonGasWarningRadius'),
133
+ redZone: zoneOf(state, 'redZonePosition', 'redZoneRadius'),
134
+ blackZone: zoneOf(state, 'blackZonePosition', 'blackZoneRadius'),
135
+ };
136
+ }
137
+ function rawTimestampSource(raw, match) {
138
+ if (absoluteElapsed(raw, match) !== null)
139
+ return 'absolute';
140
+ const timestamp = [raw._D, raw.timestamp, raw.createdAt, raw.time].find((value) => typeof value === 'string' && /^\d{1,2}:\d{2}(?::\d{2})?$/u.test(value.trim()));
141
+ if (typeof timestamp === 'string')
142
+ return 'clock';
143
+ const common = objectValue(raw.common);
144
+ const explicitRelative = [
145
+ raw.timeSeconds,
146
+ raw.elapsedSeconds,
147
+ raw.gameTime,
148
+ raw.game_time,
149
+ raw.elapsedTime,
150
+ common.elapsedTime,
151
+ ]
152
+ .map(numberValue)
153
+ .some((value) => value !== null && value > 0 && value < 24 * 60 * 60);
154
+ return explicitRelative ? 'elapsed' : 'unknown';
155
+ }
156
+ function sourceEvent(event, raw, match) {
157
+ if (!BATTLE_DOMAIN_TYPES.has(event.type))
158
+ return null;
159
+ const timeSeconds = event.timeSeconds;
160
+ return {
161
+ id: event.id,
162
+ sourceEventId: event.id,
163
+ type: event.type,
164
+ rawType: event.rawType,
165
+ timeSeconds,
166
+ timestampSource: timeSeconds === null ? 'unknown' : rawTimestampSource(raw, match),
167
+ phase: explicitPhase(raw) ?? event.phase ?? 'unknown',
168
+ actorId: event.actorId,
169
+ victimId: event.victimId,
170
+ actorTeamId: event.actorTeamId,
171
+ victimTeamId: event.victimTeamId,
172
+ attackId: event.attackId,
173
+ dbnoId: event.dbnoId,
174
+ semanticKey: combatEventKey(event),
175
+ damage: event.damage,
176
+ hit: event.hit,
177
+ weaponId: event.weaponId,
178
+ blueZone: event.blueZone ?? null,
179
+ damageReason: event.damageReason ?? null,
180
+ location: event.location,
181
+ };
182
+ }
183
+ function explicitPhase(raw) {
184
+ const value = objectValue(raw.common).isGame;
185
+ if (typeof value === 'boolean')
186
+ return value ? 'in_match' : 'pre_match';
187
+ const phase = numberValue(value);
188
+ if (phase === null)
189
+ return null;
190
+ if (phase >= 0 && phase < 1)
191
+ return 'pre_match';
192
+ if (phase >= 1)
193
+ return 'in_match';
194
+ return 'unknown';
195
+ }
196
+ export function normalizeBattleTelemetry(payload, match) {
197
+ const rawEvents = parseTelemetryPayload(payload);
198
+ const normalized = normalizeTelemetryEvents(payload, match);
199
+ const rawById = new Map(rawEvents.map((value, index) => [`telemetry-${index + 1}`, objectValue(value)]));
200
+ const events = [];
201
+ const circleSamples = [];
202
+ let excludedPreMatchEventCount = 0;
203
+ let excludedPreMatchContactEventCount = 0;
204
+ let untimedBattleEventCount = 0;
205
+ for (const event of normalized) {
206
+ const converted = sourceEvent(event, rawById.get(event.id) ?? {}, match);
207
+ if (!converted)
208
+ continue;
209
+ if (converted.phase === 'pre_match') {
210
+ excludedPreMatchEventCount += 1;
211
+ if (COMBAT_CONTACT_TYPES.has(converted.type))
212
+ excludedPreMatchContactEventCount += 1;
213
+ continue;
214
+ }
215
+ if (converted.timeSeconds === null)
216
+ untimedBattleEventCount += 1;
217
+ events.push(converted);
218
+ }
219
+ rawEvents.forEach((rawEvent, index) => {
220
+ const circle = normalizeCircle(rawEvent, index, match);
221
+ if (circle && circle.phase !== 'pre_match')
222
+ circleSamples.push(circle);
223
+ });
224
+ const recognized = new Set(normalized.map((event) => event.id));
225
+ for (const sample of circleSamples)
226
+ recognized.add(sample.sourceEventId);
227
+ return {
228
+ schemaVersion: BATTLE_RECONSTRUCTION_SCHEMA_VERSION,
229
+ parserVersion: BATTLE_RECONSTRUCTION_PARSER_VERSION,
230
+ rawEventCount: rawEvents.length,
231
+ domainNormalizedEventCount: normalized.length,
232
+ events: events.sort(eventOrder),
233
+ circleSamples: circleSamples.sort(circleOrder),
234
+ diagnostics: {
235
+ unrecognizedDomainEventCount: Math.max(0, rawEvents.length - recognized.size),
236
+ excludedPreMatchEventCount,
237
+ excludedPreMatchContactEventCount,
238
+ untimedBattleEventCount,
239
+ },
240
+ };
241
+ }
242
+ function eventOrder(left, right) {
243
+ const ordinal = (event) => Number(event.sourceEventId.match(/^(?:telemetry-)?(\d+)$/u)?.[1] ?? Number.MAX_SAFE_INTEGER);
244
+ return ((left.timeSeconds ?? Number.POSITIVE_INFINITY) -
245
+ (right.timeSeconds ?? Number.POSITIVE_INFINITY) ||
246
+ ordinal(left) - ordinal(right) ||
247
+ left.sourceEventId.localeCompare(right.sourceEventId));
248
+ }
249
+ function circleOrder(left, right) {
250
+ const ordinal = (sample) => Number(sample.sourceEventId.match(/^(?:telemetry-)?(\d+)$/u)?.[1] ?? Number.MAX_SAFE_INTEGER);
251
+ return ((left.timeSeconds ?? Number.POSITIVE_INFINITY) -
252
+ (right.timeSeconds ?? Number.POSITIVE_INFINITY) ||
253
+ ordinal(left) - ordinal(right) ||
254
+ left.sourceEventId.localeCompare(right.sourceEventId));
255
+ }
256
+ function teamMembership(events) {
257
+ const memberships = new Map();
258
+ for (const event of events) {
259
+ if (event.actorId && event.actorTeamId) {
260
+ const values = memberships.get(event.actorId) ?? new Set();
261
+ values.add(event.actorTeamId);
262
+ memberships.set(event.actorId, values);
263
+ }
264
+ if (event.victimId && event.victimTeamId) {
265
+ const values = memberships.get(event.victimId) ?? new Set();
266
+ values.add(event.victimTeamId);
267
+ memberships.set(event.victimId, values);
268
+ }
269
+ }
270
+ const playerTeam = new Map();
271
+ const teamMembers = new Map();
272
+ let conflictCount = 0;
273
+ for (const [playerId, values] of memberships) {
274
+ if (values.size !== 1) {
275
+ playerTeam.set(playerId, null);
276
+ conflictCount += 1;
277
+ continue;
278
+ }
279
+ const teamId = [...values][0];
280
+ playerTeam.set(playerId, teamId);
281
+ const members = teamMembers.get(teamId) ?? new Set();
282
+ members.add(playerId);
283
+ teamMembers.set(teamId, members);
284
+ }
285
+ return { playerTeam, teamMembers, conflictCount };
286
+ }
287
+ function resolveTeamId(explicit, playerId, playerTeam) {
288
+ if (explicit)
289
+ return explicit;
290
+ return playerId ? (playerTeam.get(playerId) ?? null) : null;
291
+ }
292
+ function contactSides(event, playerTeam) {
293
+ if (!event.actorId || !event.victimId || event.actorId === event.victimId)
294
+ return null;
295
+ const actorTeamId = resolveTeamId(event.actorTeamId, event.actorId, playerTeam);
296
+ const victimTeamId = resolveTeamId(event.victimTeamId, event.victimId, playerTeam);
297
+ if (actorTeamId && victimTeamId && actorTeamId === victimTeamId)
298
+ return null;
299
+ const actor = actorTeamId
300
+ ? { key: `team:${actorTeamId}`, teamId: actorTeamId, seedPlayerId: null }
301
+ : { key: `player:${event.actorId}`, teamId: null, seedPlayerId: event.actorId };
302
+ const victim = victimTeamId
303
+ ? { key: `team:${victimTeamId}`, teamId: victimTeamId, seedPlayerId: null }
304
+ : { key: `player:${event.victimId}`, teamId: null, seedPlayerId: event.victimId };
305
+ return actor.key.localeCompare(victim.key) <= 0 ? [actor, victim] : [victim, actor];
306
+ }
307
+ function pairKey(sides) {
308
+ return `${sides[0].key}\u001f${sides[1].key}`;
309
+ }
310
+ function contactIsEffective(event) {
311
+ return event.type !== 'DAMAGE' || event.damage > 0 || event.hit;
312
+ }
313
+ function matchesKnockOutcome(knock, outcome) {
314
+ if (outcome.type !== 'KILL' && outcome.type !== 'REVIVE')
315
+ return false;
316
+ if (knock.victimId && outcome.victimId)
317
+ return knock.victimId === outcome.victimId;
318
+ return Boolean(knock.dbnoId && outcome.dbnoId && knock.dbnoId === outcome.dbnoId);
319
+ }
320
+ function knockOutcome(knock, events) {
321
+ if (knock.timeSeconds === null)
322
+ return null;
323
+ const candidates = events.filter((event) => event.timeSeconds !== null &&
324
+ event.timeSeconds >= knock.timeSeconds &&
325
+ event.timeSeconds - knock.timeSeconds <= BATTLE_KNOCK_OUTCOME_WINDOW_SECONDS &&
326
+ matchesKnockOutcome(knock, event));
327
+ candidates.sort(eventOrder);
328
+ const firstKnockIndex = events.findIndex((event) => event.id === knock.id);
329
+ const nextKnock = events
330
+ .slice(firstKnockIndex + 1)
331
+ .find((event) => event.type === 'KNOCK' &&
332
+ event.victimId === knock.victimId &&
333
+ event.timeSeconds !== null &&
334
+ event.timeSeconds > knock.timeSeconds);
335
+ const nextKnockTime = nextKnock?.timeSeconds ?? Number.POSITIVE_INFINITY;
336
+ return candidates.find((candidate) => candidate.timeSeconds < nextKnockTime) ?? null;
337
+ }
338
+ function directKillFollows(session, event) {
339
+ if (event.type !== 'KILL' || event.timeSeconds === null)
340
+ return false;
341
+ return session.pendingKnocks.some((knock) => knock.timeSeconds !== null &&
342
+ event.timeSeconds >= knock.timeSeconds &&
343
+ event.timeSeconds - knock.timeSeconds <= BATTLE_KNOCK_OUTCOME_WINDOW_SECONDS &&
344
+ matchesKnockOutcome(knock, event));
345
+ }
346
+ function sessionId(matchId, session) {
347
+ const contactIds = session.seedEvents.map((event) => event.sourceEventId).join(',');
348
+ return `episode-${idDigest(`${BATTLE_RECONSTRUCTION_SCHEMA_VERSION}\u001f${BATTLE_RECONSTRUCTION_PARSER_VERSION}\u001f${matchId}\u001f${session.pairKey}\u001f${contactIds}`)}`;
349
+ }
350
+ function segmentContacts(events, playerTeam, matchId) {
351
+ const counters = {
352
+ duplicateSourceEventCount: 0,
353
+ untimedContactEventCount: 0,
354
+ preMatchContactEventCount: 0,
355
+ noParticipantContactEventCount: 0,
356
+ sameTeamContactEventCount: 0,
357
+ ineffectiveContactEventCount: 0,
358
+ unknownPhaseContactEventCount: 0,
359
+ unknownTeamContactEventCount: 0,
360
+ };
361
+ const seenSourceIds = new Set();
362
+ const seenSemanticIds = new Set();
363
+ const byPair = new Map();
364
+ for (const event of events
365
+ .filter((candidate) => COMBAT_CONTACT_TYPES.has(candidate.type))
366
+ .sort(eventOrder)) {
367
+ if (seenSourceIds.has(event.sourceEventId)) {
368
+ counters.duplicateSourceEventCount += 1;
369
+ continue;
370
+ }
371
+ seenSourceIds.add(event.sourceEventId);
372
+ if (event.phase === 'pre_match') {
373
+ counters.preMatchContactEventCount += 1;
374
+ continue;
375
+ }
376
+ if (event.phase === 'unknown') {
377
+ counters.unknownPhaseContactEventCount += 1;
378
+ continue;
379
+ }
380
+ if (event.timeSeconds === null) {
381
+ counters.untimedContactEventCount += 1;
382
+ continue;
383
+ }
384
+ if (!event.actorId || !event.victimId) {
385
+ counters.noParticipantContactEventCount += 1;
386
+ continue;
387
+ }
388
+ const actorTeam = resolveTeamId(event.actorTeamId, event.actorId, playerTeam);
389
+ const victimTeam = resolveTeamId(event.victimTeamId, event.victimId, playerTeam);
390
+ if (actorTeam && victimTeam && actorTeam === victimTeam) {
391
+ counters.sameTeamContactEventCount += 1;
392
+ continue;
393
+ }
394
+ if (!contactIsEffective(event)) {
395
+ counters.ineffectiveContactEventCount += 1;
396
+ continue;
397
+ }
398
+ const sides = contactSides(event, playerTeam);
399
+ if (!sides) {
400
+ counters.noParticipantContactEventCount += 1;
401
+ continue;
402
+ }
403
+ const key = pairKey(sides);
404
+ const semantic = event.semanticKey;
405
+ const semanticUniqueKey = `${key}\u001f${semantic}`;
406
+ if (seenSemanticIds.has(semanticUniqueKey)) {
407
+ counters.duplicateSourceEventCount += 1;
408
+ continue;
409
+ }
410
+ seenSemanticIds.add(semanticUniqueKey);
411
+ if (sides.some((side) => !side.teamId))
412
+ counters.unknownTeamContactEventCount += 1;
413
+ const entry = byPair.get(key) ?? { sides, events: [] };
414
+ entry.events.push(event);
415
+ byPair.set(key, entry);
416
+ }
417
+ const sessions = [];
418
+ for (const [key, pair] of [...byPair.entries()].sort(([left], [right]) => left.localeCompare(right))) {
419
+ let current;
420
+ for (const event of pair.events.sort(eventOrder)) {
421
+ const last = current?.seedEvents.at(-1)?.timeSeconds ?? null;
422
+ const connectedByGap = last !== null && event.timeSeconds - last <= BATTLE_CONTACT_GAP_SECONDS;
423
+ const connectedByKnockOutcome = current ? directKillFollows(current, event) : false;
424
+ if (!current || (!connectedByGap && !connectedByKnockOutcome)) {
425
+ current = {
426
+ pairKey: key,
427
+ sides: pair.sides,
428
+ seedEvents: [],
429
+ startSeconds: event.timeSeconds,
430
+ endSeconds: event.timeSeconds,
431
+ pendingKnocks: [],
432
+ assigned: [],
433
+ positions: [],
434
+ circleSampleRefs: [],
435
+ id: '',
436
+ diagnostics: {
437
+ unknownTeamContacts: 0,
438
+ ambiguousContextEvents: 0,
439
+ positionsSuppressedBySampling: 0,
440
+ conflictingStateTransitions: 0,
441
+ },
442
+ };
443
+ sessions.push(current);
444
+ }
445
+ current.seedEvents.push(event);
446
+ current.assigned.push(event);
447
+ if (current.sides.some((side) => !side.teamId))
448
+ current.diagnostics.unknownTeamContacts += 1;
449
+ current.endSeconds = event.timeSeconds;
450
+ if (event.type === 'KNOCK')
451
+ current.pendingKnocks.push(event);
452
+ if (event.type === 'KILL')
453
+ current.pendingKnocks = current.pendingKnocks.filter((knock) => !matchesKnockOutcome(knock, event));
454
+ }
455
+ }
456
+ for (const session of sessions)
457
+ session.id = sessionId(matchId, session);
458
+ return {
459
+ sessions: sessions.sort((left, right) => left.startSeconds - right.startSeconds || left.id.localeCompare(right.id)),
460
+ counters,
461
+ };
462
+ }
463
+ function sideContainsEvent(session, event, playerTeam) {
464
+ const actorTeam = resolveTeamId(event.actorTeamId, event.actorId, playerTeam);
465
+ const victimTeam = resolveTeamId(event.victimTeamId, event.victimId, playerTeam);
466
+ const teamIds = new Set(session.sides.map((side) => side.teamId).filter((value) => Boolean(value)));
467
+ const playerIds = new Set(session.sides
468
+ .map((side) => side.seedPlayerId)
469
+ .filter((value) => Boolean(value)));
470
+ return Boolean((actorTeam && teamIds.has(actorTeam)) ||
471
+ (victimTeam && teamIds.has(victimTeam)) ||
472
+ (event.actorId && playerIds.has(event.actorId)) ||
473
+ (event.victimId && playerIds.has(event.victimId)));
474
+ }
475
+ function distanceToSession(timeSeconds, session) {
476
+ if (timeSeconds < session.startSeconds)
477
+ return session.startSeconds - timeSeconds;
478
+ if (timeSeconds > session.endSeconds)
479
+ return timeSeconds - session.endSeconds;
480
+ return 0;
481
+ }
482
+ function chooseUniqueSession(event, sessions, playerTeam) {
483
+ if (event.timeSeconds === null)
484
+ return null;
485
+ const candidates = sessions.filter((session) => sideContainsEvent(session, event, playerTeam) &&
486
+ event.timeSeconds >= session.startSeconds - BATTLE_CONTEXT_BEFORE_SECONDS &&
487
+ event.timeSeconds <= session.endSeconds + BATTLE_CONTEXT_AFTER_SECONDS);
488
+ if (!candidates.length)
489
+ return null;
490
+ candidates.sort((left, right) => distanceToSession(event.timeSeconds, left) - distanceToSession(event.timeSeconds, right) ||
491
+ left.id.localeCompare(right.id));
492
+ const bestDistance = distanceToSession(event.timeSeconds, candidates[0]);
493
+ const ties = candidates.filter((candidate) => distanceToSession(event.timeSeconds, candidate) === bestDistance);
494
+ return ties.length === 1 ? ties[0] : 'ambiguous';
495
+ }
496
+ function assignRevives(events, sessions) {
497
+ let assigned = 0;
498
+ let ambiguous = 0;
499
+ const revives = events
500
+ .filter((event) => event.type === 'REVIVE' && event.phase !== 'pre_match')
501
+ .sort(eventOrder);
502
+ for (const revive of revives) {
503
+ if (revive.timeSeconds === null) {
504
+ ambiguous += 1;
505
+ continue;
506
+ }
507
+ const candidates = sessions.filter((session) => session.seedEvents.some((knock) => knock.type === 'KNOCK' && knockOutcome(knock, events)?.id === revive.id));
508
+ if (candidates.length !== 1) {
509
+ ambiguous += 1;
510
+ continue;
511
+ }
512
+ const session = candidates[0];
513
+ if (session.assigned.some((event) => event.sourceEventId === revive.sourceEventId))
514
+ continue;
515
+ session.assigned.push(revive);
516
+ session.endSeconds = Math.max(session.endSeconds, revive.timeSeconds);
517
+ assigned += 1;
518
+ }
519
+ return { assigned, ambiguous };
520
+ }
521
+ function assignContextEvents(events, sessions, playerTeam) {
522
+ let ambiguous = 0;
523
+ let suppressedPositions = 0;
524
+ const contextEvents = events
525
+ .filter((event) => TIMELINE_CONTEXT_TYPES.has(event.type) &&
526
+ event.timeSeconds !== null &&
527
+ event.phase !== 'pre_match')
528
+ .sort(eventOrder);
529
+ const assignedIds = new Set(sessions.flatMap((session) => session.assigned.map((event) => event.sourceEventId)));
530
+ for (const event of contextEvents) {
531
+ if (assignedIds.has(event.sourceEventId))
532
+ continue;
533
+ const selected = chooseUniqueSession(event, sessions, playerTeam);
534
+ if (selected === 'ambiguous') {
535
+ ambiguous += 1;
536
+ const candidates = sessions.filter((session) => sideContainsEvent(session, event, playerTeam) &&
537
+ event.timeSeconds >= session.startSeconds - BATTLE_CONTEXT_BEFORE_SECONDS &&
538
+ event.timeSeconds <= session.endSeconds + BATTLE_CONTEXT_AFTER_SECONDS);
539
+ const nearest = Math.min(...candidates.map((session) => distanceToSession(event.timeSeconds, session)));
540
+ for (const candidate of candidates) {
541
+ if (distanceToSession(event.timeSeconds, candidate) === nearest)
542
+ candidate.diagnostics.ambiguousContextEvents += 1;
543
+ }
544
+ continue;
545
+ }
546
+ if (!selected)
547
+ continue;
548
+ if (event.type !== 'POSITION' ||
549
+ !event.actorId ||
550
+ !event.location ||
551
+ event.location.x === null ||
552
+ event.location.y === null) {
553
+ selected.assigned.push(event);
554
+ assignedIds.add(event.sourceEventId);
555
+ continue;
556
+ }
557
+ const bucket = Math.floor(event.timeSeconds / BATTLE_POSITION_SAMPLE_INTERVAL_SECONDS);
558
+ const previous = selected.positions.find((sample) => sample.playerRef === event.actorId &&
559
+ Math.floor(sample.timeSeconds / BATTLE_POSITION_SAMPLE_INTERVAL_SECONDS) === bucket);
560
+ if (previous) {
561
+ suppressedPositions += 1;
562
+ selected.diagnostics.positionsSuppressedBySampling += 1;
563
+ continue;
564
+ }
565
+ const teamId = resolveTeamId(event.actorTeamId, event.actorId, playerTeam);
566
+ selected.positions.push({
567
+ sourceEventId: event.sourceEventId,
568
+ playerRef: event.actorId,
569
+ teamRef: teamId,
570
+ timeSeconds: event.timeSeconds,
571
+ location: { x: event.location.x, y: event.location.y, z: event.location.z },
572
+ });
573
+ selected.assigned.push(event);
574
+ assignedIds.add(event.sourceEventId);
575
+ }
576
+ for (const session of sessions) {
577
+ session.assigned = [
578
+ ...new Map(session.assigned.map((event) => [event.sourceEventId, event])).values(),
579
+ ].sort(eventOrder);
580
+ session.positions.sort((left, right) => left.timeSeconds - right.timeSeconds ||
581
+ left.sourceEventId.localeCompare(right.sourceEventId));
582
+ }
583
+ return { ambiguous, suppressedPositions };
584
+ }
585
+ function stateAtEnd(session, events, teamMembers, playerTeam, matchId) {
586
+ let conflictCount = 0;
587
+ const conflictEventIds = new Set();
588
+ const noteConflict = (event) => {
589
+ conflictCount += 1;
590
+ conflictEventIds.add(event.sourceEventId);
591
+ };
592
+ const stateByPlayer = new Map();
593
+ const observedPlayerIds = new Set(session.sides.flatMap((side) => side.teamId
594
+ ? [...(teamMembers.get(side.teamId) ?? [])]
595
+ : side.seedPlayerId
596
+ ? [side.seedPlayerId]
597
+ : []));
598
+ const seenStateEvents = new Set();
599
+ const ordered = events
600
+ .filter((event) => event.timeSeconds !== null &&
601
+ event.timeSeconds <= session.endSeconds &&
602
+ event.phase !== 'pre_match' &&
603
+ Boolean((event.actorId && observedPlayerIds.has(event.actorId)) ||
604
+ (event.victimId && observedPlayerIds.has(event.victimId))))
605
+ .sort(eventOrder)
606
+ .filter((event) => {
607
+ const key = `${event.type}\u001f${event.semanticKey}`;
608
+ if (seenStateEvents.has(key))
609
+ return false;
610
+ seenStateEvents.add(key);
611
+ return true;
612
+ });
613
+ for (const event of ordered) {
614
+ if (event.actorId &&
615
+ observedPlayerIds.has(event.actorId) &&
616
+ (COMBAT_CONTACT_TYPES.has(event.type) || event.type === 'REVIVE' || event.type === 'ATTACK')) {
617
+ const state = stateByPlayer.get(event.actorId);
618
+ if (state?.state === 'DEAD')
619
+ noteConflict(event);
620
+ else
621
+ stateByPlayer.set(event.actorId, { state: 'ACTIVE_OBSERVED', time: event.timeSeconds });
622
+ }
623
+ if (!event.victimId ||
624
+ !observedPlayerIds.has(event.victimId) ||
625
+ (event.type !== 'KNOCK' && event.type !== 'KILL' && event.type !== 'REVIVE'))
626
+ continue;
627
+ const previous = stateByPlayer.get(event.victimId);
628
+ if (event.type === 'KNOCK') {
629
+ if (previous?.state === 'DEAD')
630
+ noteConflict(event);
631
+ else
632
+ stateByPlayer.set(event.victimId, { state: 'KNOCKED', time: event.timeSeconds });
633
+ }
634
+ else if (event.type === 'KILL') {
635
+ if (previous?.state === 'DEAD')
636
+ noteConflict(event);
637
+ stateByPlayer.set(event.victimId, { state: 'DEAD', time: event.timeSeconds });
638
+ }
639
+ else {
640
+ if (previous?.state !== 'KNOCKED')
641
+ noteConflict(event);
642
+ stateByPlayer.set(event.victimId, { state: 'ACTIVE_OBSERVED', time: event.timeSeconds });
643
+ }
644
+ }
645
+ const tracked = new Set();
646
+ const summaries = session.sides.map((side) => {
647
+ const memberIds = side.teamId
648
+ ? [...(teamMembers.get(side.teamId) ?? new Set())].sort()
649
+ : side.seedPlayerId
650
+ ? [side.seedPlayerId]
651
+ : [];
652
+ const states = memberIds.map((playerId) => {
653
+ const last = stateByPlayer.get(playerId);
654
+ const state = last?.state ?? 'UNKNOWN';
655
+ return {
656
+ playerRef: battlePlayerRef(matchId, playerId),
657
+ state,
658
+ lastObservedAtSeconds: last?.time ?? null,
659
+ };
660
+ });
661
+ for (const [playerId, teamId] of playerTeam)
662
+ if (teamId === side.teamId && side.teamId)
663
+ tracked.add(playerId);
664
+ return {
665
+ teamRef: side.teamId ? battleTeamRef(matchId, side.teamId) : null,
666
+ memberRefs: memberIds.map((playerId) => battlePlayerRef(matchId, playerId)),
667
+ tracked: false,
668
+ lastObserved: {
669
+ active: states.filter((item) => item.state === 'ACTIVE_OBSERVED').length,
670
+ knocked: states.filter((item) => item.state === 'KNOCKED').length,
671
+ dead: states.filter((item) => item.state === 'DEAD').length,
672
+ unknown: states.filter((item) => item.state === 'UNKNOWN').length,
673
+ completeness: 'observed-members-only',
674
+ states,
675
+ },
676
+ };
677
+ });
678
+ return {
679
+ summaries,
680
+ conflictCount,
681
+ playerRefs: [...tracked].map((playerId) => battlePlayerRef(matchId, playerId)),
682
+ conflictEventIds,
683
+ };
684
+ }
685
+ function timelineEvent(event, matchId, playerTeam) {
686
+ const actorTeamId = resolveTeamId(event.actorTeamId, event.actorId, playerTeam);
687
+ const victimTeamId = resolveTeamId(event.victimTeamId, event.victimId, playerTeam);
688
+ return {
689
+ id: event.sourceEventId,
690
+ sourceEventId: event.sourceEventId,
691
+ type: event.type,
692
+ rawType: event.rawType,
693
+ timeSeconds: event.timeSeconds,
694
+ timestampSource: event.timestampSource,
695
+ actorRef: event.actorId ? battlePlayerRef(matchId, event.actorId) : null,
696
+ victimRef: event.victimId ? battlePlayerRef(matchId, event.victimId) : null,
697
+ actorTeamRef: actorTeamId ? battleTeamRef(matchId, actorTeamId) : null,
698
+ victimTeamRef: victimTeamId ? battleTeamRef(matchId, victimTeamId) : null,
699
+ damage: event.type === 'DAMAGE' ? event.damage : null,
700
+ weapon: event.weaponId,
701
+ location: vectorOf(event.location),
702
+ };
703
+ }
704
+ function chainForKnock(knock, assigned, events, matchId) {
705
+ const outcome = knockOutcome(knock, events);
706
+ const episodeOutcome = outcome && assigned.some((event) => event.id === outcome.id) ? outcome : null;
707
+ return {
708
+ knockEventId: knock.sourceEventId,
709
+ knockedPlayerRef: knock.victimId ? battlePlayerRef(matchId, knock.victimId) : null,
710
+ attackerRef: knock.actorId ? battlePlayerRef(matchId, knock.actorId) : null,
711
+ knockAtSeconds: knock.timeSeconds,
712
+ outcome: episodeOutcome?.type === 'KILL' || episodeOutcome?.type === 'REVIVE'
713
+ ? episodeOutcome.type
714
+ : null,
715
+ outcomeEventId: episodeOutcome?.sourceEventId ?? null,
716
+ outcomeAtSeconds: episodeOutcome?.timeSeconds ?? null,
717
+ elapsedSeconds: episodeOutcome && episodeOutcome.timeSeconds !== null
718
+ ? Math.max(0, episodeOutcome.timeSeconds - knock.timeSeconds)
719
+ : null,
720
+ windowSeconds: BATTLE_KNOCK_OUTCOME_WINDOW_SECONDS,
721
+ };
722
+ }
723
+ function circleForEpisode(session, samples) {
724
+ return samples.filter((sample) => sample.timeSeconds !== null &&
725
+ sample.phase === 'in_match' &&
726
+ sample.timeSeconds >= session.startSeconds - BATTLE_CONTEXT_BEFORE_SECONDS &&
727
+ sample.timeSeconds <= session.endSeconds + BATTLE_CONTEXT_AFTER_SECONDS);
728
+ }
729
+ function circleTransitions(samples, matchId) {
730
+ const zones = ['safetyZone', 'poisonGasWarningZone', 'redZone', 'blackZone'];
731
+ const transitions = [];
732
+ for (let index = 1; index < samples.length; index += 1) {
733
+ const previous = samples[index - 1];
734
+ const current = samples[index];
735
+ if (previous.timeSeconds === null ||
736
+ current.timeSeconds === null ||
737
+ current.timeSeconds < previous.timeSeconds)
738
+ continue;
739
+ const changedZones = zones.filter((zone) => JSON.stringify(previous[zone]) !== JSON.stringify(current[zone]));
740
+ if (!changedZones.length)
741
+ continue;
742
+ transitions.push({
743
+ id: `circle-transition-${idDigest(`${matchId}\u001f${previous.id}\u001f${current.id}\u001f${changedZones.join(',')}`)}`,
744
+ fromSampleId: previous.id,
745
+ toSampleId: current.id,
746
+ fromSourceEventId: previous.sourceEventId,
747
+ toSourceEventId: current.sourceEventId,
748
+ timeSeconds: current.timeSeconds,
749
+ changedZones: [...changedZones],
750
+ });
751
+ }
752
+ return transitions;
753
+ }
754
+ function episodeForSession(session, events, circles, transitions, teamMembers, playerTeam, trackedPlayerIds, matchId, globalStateConflictEventIds) {
755
+ const uniqueTimeline = [
756
+ ...new Map(session.assigned.map((event) => [event.sourceEventId, event])).values(),
757
+ ].sort(eventOrder);
758
+ const states = stateAtEnd(session, events, teamMembers, playerTeam, matchId);
759
+ for (const eventId of states.conflictEventIds)
760
+ globalStateConflictEventIds.add(eventId);
761
+ const teams = states.summaries.map((team, index) => ({
762
+ ...team,
763
+ tracked: session.sides[index].teamId
764
+ ? [...(teamMembers.get(session.sides[index].teamId) ?? [])].some((playerId) => trackedPlayerIds.has(playerId))
765
+ : Boolean(session.sides[index].seedPlayerId &&
766
+ trackedPlayerIds.has(session.sides[index].seedPlayerId)),
767
+ }));
768
+ const knocks = session.seedEvents.filter((event) => event.type === 'KNOCK').sort(eventOrder);
769
+ const chains = knocks.map((knock) => chainForKnock(knock, uniqueTimeline, events, matchId));
770
+ const first = knocks[0];
771
+ const eventRecords = uniqueTimeline.map((event) => timelineEvent(event, matchId, playerTeam));
772
+ const circleSamplesForEpisode = circleForEpisode(session, circles);
773
+ session.circleSampleRefs = circleSamplesForEpisode.map((sample) => sample.id);
774
+ const circleSampleRefs = new Set(session.circleSampleRefs);
775
+ const circleTransitionRefs = transitions
776
+ .filter((transition) => circleSampleRefs.has(transition.fromSampleId) &&
777
+ circleSampleRefs.has(transition.toSampleId))
778
+ .map((transition) => transition.id);
779
+ const evidenceEventIds = [
780
+ ...new Set([
781
+ ...uniqueTimeline.map((event) => event.sourceEventId),
782
+ ...session.circleSampleRefs,
783
+ ...circleTransitionRefs,
784
+ ]),
785
+ ];
786
+ return {
787
+ id: session.id,
788
+ startSeconds: session.startSeconds,
789
+ endSeconds: session.endSeconds,
790
+ durationSeconds: Math.max(0, session.endSeconds - session.startSeconds),
791
+ contactGapSeconds: BATTLE_CONTACT_GAP_SECONDS,
792
+ participantRefs: [
793
+ ...new Set(uniqueTimeline.flatMap((event) => [event.actorId, event.victimId].filter((value) => Boolean(value)))),
794
+ ]
795
+ .map((playerId) => battlePlayerRef(matchId, playerId))
796
+ .sort(),
797
+ teams,
798
+ firstKnock: first
799
+ ? {
800
+ sourceEventId: first.sourceEventId,
801
+ attackerRef: first.actorId ? battlePlayerRef(matchId, first.actorId) : null,
802
+ victimRef: first.victimId ? battlePlayerRef(matchId, first.victimId) : null,
803
+ timeSeconds: first.timeSeconds,
804
+ }
805
+ : null,
806
+ knockChains: chains,
807
+ timeline: eventRecords,
808
+ positions: session.positions.map((position) => ({
809
+ sourceEventId: position.sourceEventId,
810
+ playerRef: battlePlayerRef(matchId, position.playerRef),
811
+ teamRef: position.teamRef ? battleTeamRef(matchId, position.teamRef) : null,
812
+ timeSeconds: position.timeSeconds,
813
+ location: position.location,
814
+ })),
815
+ circleSampleRefs: session.circleSampleRefs,
816
+ circleTransitionRefs,
817
+ evidenceEventIds,
818
+ diagnostics: {
819
+ conflictingStateTransitions: states.conflictCount,
820
+ unknownTeamContacts: session.diagnostics.unknownTeamContacts,
821
+ ambiguousContextEvents: session.diagnostics.ambiguousContextEvents,
822
+ positionsSuppressedBySampling: session.diagnostics.positionsSuppressedBySampling,
823
+ },
824
+ };
825
+ }
826
+ function episodeContactEventIds(episode) {
827
+ return episode.timeline
828
+ .filter((event) => COMBAT_CONTACT_TYPES.has(event.type))
829
+ .map((event) => event.sourceEventId);
830
+ }
831
+ function battleGroupsFor(matchId, episodes, events) {
832
+ const parent = episodes.map((_, index) => index);
833
+ const find = (index) => {
834
+ if (parent[index] !== index)
835
+ parent[index] = find(parent[index]);
836
+ return parent[index];
837
+ };
838
+ const bridgeEdges = [];
839
+ for (let left = 0; left < episodes.length; left += 1) {
840
+ for (let right = left + 1; right < episodes.length; right += 1) {
841
+ const a = episodes[left];
842
+ const b = episodes[right];
843
+ const startsOverlap = Math.max(a.startSeconds, b.startSeconds);
844
+ const endsOverlap = Math.min(a.endSeconds, b.endSeconds);
845
+ if (startsOverlap > endsOverlap)
846
+ continue;
847
+ const aTeams = new Set(a.teams.map((team) => team.teamRef).filter((value) => Boolean(value)));
848
+ const bTeams = new Set(b.teams.map((team) => team.teamRef).filter((value) => Boolean(value)));
849
+ if (![...aTeams].some((teamRef) => bTeams.has(teamRef)))
850
+ continue;
851
+ const nearestContactEvidence = (episode) => episode.timeline
852
+ .filter((event) => COMBAT_CONTACT_TYPES.has(event.type))
853
+ .sort((first, second) => {
854
+ const distance = (time) => time < startsOverlap
855
+ ? startsOverlap - time
856
+ : time > endsOverlap
857
+ ? time - endsOverlap
858
+ : 0;
859
+ return (distance(first.timeSeconds) - distance(second.timeSeconds) ||
860
+ first.timeSeconds - second.timeSeconds ||
861
+ first.sourceEventId.localeCompare(second.sourceEventId));
862
+ })
863
+ .slice(0, 1)
864
+ .map((event) => event.sourceEventId);
865
+ const evidenceEventIds = [
866
+ ...new Set([...nearestContactEvidence(a), ...nearestContactEvidence(b)]),
867
+ ];
868
+ if (evidenceEventIds.length < 2)
869
+ continue;
870
+ const leftRoot = find(left);
871
+ const rightRoot = find(right);
872
+ if (leftRoot !== rightRoot)
873
+ parent[rightRoot] = leftRoot;
874
+ bridgeEdges.push({ left, right, eventIds: evidenceEventIds });
875
+ }
876
+ }
877
+ const indicesByRoot = new Map();
878
+ episodes.forEach((_, index) => {
879
+ const root = find(index);
880
+ indicesByRoot.set(root, [...(indicesByRoot.get(root) ?? []), index]);
881
+ });
882
+ const eventById = new Map(events.map((event) => [event.sourceEventId, event]));
883
+ const groups = [...indicesByRoot.values()]
884
+ .map((indices) => {
885
+ const groupEpisodes = indices
886
+ .map((index) => episodes[index])
887
+ .sort((left, right) => left.startSeconds - right.startSeconds || left.id.localeCompare(right.id));
888
+ const episodeIds = groupEpisodes.map((episode) => episode.id).sort();
889
+ const indexSet = new Set(indices);
890
+ const bridgeEvidenceEventIds = [
891
+ ...new Set(bridgeEdges
892
+ .filter((edge) => indexSet.has(edge.left) && indexSet.has(edge.right))
893
+ .flatMap((edge) => edge.eventIds)),
894
+ ].sort();
895
+ const timelineBySource = new Map();
896
+ for (const episode of groupEpisodes) {
897
+ for (const event of episode.timeline)
898
+ timelineBySource.set(event.sourceEventId, event);
899
+ }
900
+ const timeline = [...timelineBySource.values()].sort((left, right) => left.timeSeconds - right.timeSeconds ||
901
+ left.sourceEventId.localeCompare(right.sourceEventId));
902
+ const teamRefs = [
903
+ ...new Set(groupEpisodes.flatMap((episode) => episode.teams
904
+ .map((team) => team.teamRef)
905
+ .filter((value) => Boolean(value)))),
906
+ ].sort();
907
+ const startSeconds = Math.min(...groupEpisodes.map((episode) => episode.startSeconds));
908
+ const endSeconds = Math.max(...groupEpisodes.map((episode) => episode.endSeconds));
909
+ const groupId = `battle-${idDigest(`${matchId}\u001f${BATTLE_RECONSTRUCTION_PARSER_VERSION}\u001f${episodeIds.join('\u001f')}`)}`;
910
+ const playerStates = new Map();
911
+ const playerTeams = new Map();
912
+ const membersByTeam = new Map();
913
+ for (const episode of groupEpisodes) {
914
+ for (const team of episode.teams) {
915
+ if (!team.teamRef)
916
+ continue;
917
+ const members = membersByTeam.get(team.teamRef) ?? new Set();
918
+ for (const ref of team.memberRefs) {
919
+ members.add(ref);
920
+ playerTeams.set(ref, team.teamRef);
921
+ }
922
+ membersByTeam.set(team.teamRef, members);
923
+ }
924
+ }
925
+ const stateEvents = timeline.flatMap((timelineEvent) => {
926
+ const event = eventById.get(timelineEvent.sourceEventId);
927
+ if (!event || (event.type !== 'KNOCK' && event.type !== 'KILL' && event.type !== 'REVIVE'))
928
+ return [];
929
+ return [{ timelineEvent, event }];
930
+ });
931
+ const countStates = () => teamRefs.map((teamRef) => {
932
+ const members = membersByTeam.get(teamRef) ?? new Set();
933
+ let activeObserved = 0;
934
+ let knockedObserved = 0;
935
+ let deadObserved = 0;
936
+ let unknownObserved = 0;
937
+ for (const member of members) {
938
+ const state = playerStates.get(member) ?? 'UNKNOWN';
939
+ if (state === 'ACTIVE_OBSERVED')
940
+ activeObserved += 1;
941
+ else if (state === 'KNOCKED')
942
+ knockedObserved += 1;
943
+ else if (state === 'DEAD')
944
+ deadObserved += 1;
945
+ else
946
+ unknownObserved += 1;
947
+ }
948
+ return {
949
+ teamRef,
950
+ memberCountObserved: members.size,
951
+ activeObserved,
952
+ knockedObserved,
953
+ deadObserved,
954
+ unknownObserved,
955
+ completeness: 'observed-members-only',
956
+ };
957
+ });
958
+ const stateTimeline = [];
959
+ for (const { timelineEvent, event } of stateEvents) {
960
+ const actorRef = timelineEvent.actorRef;
961
+ const victimRef = timelineEvent.victimRef;
962
+ if (actorRef && !playerStates.has(actorRef))
963
+ playerStates.set(actorRef, 'ACTIVE_OBSERVED');
964
+ if (victimRef && !playerStates.has(victimRef))
965
+ playerStates.set(victimRef, 'ACTIVE_OBSERVED');
966
+ if (victimRef) {
967
+ const previous = playerStates.get(victimRef) ?? 'UNKNOWN';
968
+ if (event.type === 'KNOCK' && previous !== 'DEAD')
969
+ playerStates.set(victimRef, 'KNOCKED');
970
+ if (event.type === 'KILL')
971
+ playerStates.set(victimRef, 'DEAD');
972
+ if (event.type === 'REVIVE' && previous !== 'DEAD')
973
+ playerStates.set(victimRef, 'ACTIVE_OBSERVED');
974
+ }
975
+ stateTimeline.push({
976
+ sourceEventId: event.sourceEventId,
977
+ timeSeconds: event.timeSeconds ?? timelineEvent.timeSeconds,
978
+ type: event.type,
979
+ actorRef: timelineEvent.actorRef,
980
+ victimRef: timelineEvent.victimRef,
981
+ actorTeamRef: timelineEvent.actorTeamRef,
982
+ victimTeamRef: timelineEvent.victimTeamRef,
983
+ states: countStates(),
984
+ });
985
+ }
986
+ const advantageWindows = [];
987
+ const advantageChanges = [];
988
+ for (let leftIndex = 0; leftIndex < teamRefs.length; leftIndex += 1) {
989
+ for (let rightIndex = leftIndex + 1; rightIndex < teamRefs.length; rightIndex += 1) {
990
+ const pair = [teamRefs[leftIndex], teamRefs[rightIndex]];
991
+ const downByPlayer = new Map();
992
+ let previousLead = null;
993
+ let priorNonNullLead = null;
994
+ let open = null;
995
+ const close = (at, sourceEventId, resolution) => {
996
+ if (!open)
997
+ return;
998
+ advantageWindows.push({
999
+ teamRefs: pair,
1000
+ observedLeadTeamRef: open.leader,
1001
+ startedAtSeconds: open.start,
1002
+ endedAtSeconds: at,
1003
+ durationSeconds: Math.max(0, at - open.start),
1004
+ resolution,
1005
+ startEventId: open.sourceEventId,
1006
+ endEventId: sourceEventId,
1007
+ evidenceEventIds: [
1008
+ ...new Set([open.sourceEventId, ...(sourceEventId ? [sourceEventId] : [])]),
1009
+ ],
1010
+ assessment: 'observed-knock-balance-only',
1011
+ rosterCompleteness: 'observed-members-only',
1012
+ });
1013
+ open = null;
1014
+ };
1015
+ for (const { event } of stateEvents) {
1016
+ if (!event.victimId ||
1017
+ !event.victimTeamId ||
1018
+ !pair.includes(battleTeamRef(matchId, event.victimTeamId)) ||
1019
+ event.timeSeconds === null)
1020
+ continue;
1021
+ if (event.type === 'KNOCK' || event.type === 'KILL')
1022
+ downByPlayer.set(event.victimId, true);
1023
+ if (event.type === 'REVIVE')
1024
+ downByPlayer.set(event.victimId, false);
1025
+ const downCount = (teamRef) => [...downByPlayer.entries()].filter(([playerId, down]) => down && playerTeams.get(battlePlayerRef(matchId, playerId)) === teamRef).length;
1026
+ const [leftDown, rightDown] = pair.map(downCount);
1027
+ const nextLead = leftDown < rightDown ? pair[0] : rightDown < leftDown ? pair[1] : null;
1028
+ if (nextLead !== previousLead) {
1029
+ const classification = previousLead && !nextLead
1030
+ ? 'equalized'
1031
+ : nextLead && priorNonNullLead && nextLead !== priorNonNullLead
1032
+ ? 'reversed'
1033
+ : nextLead
1034
+ ? 'lead'
1035
+ : 'equalized';
1036
+ advantageChanges.push({
1037
+ teamRefs: pair,
1038
+ timeSeconds: event.timeSeconds,
1039
+ previousLeadTeamRef: previousLead,
1040
+ nextLeadTeamRef: nextLead,
1041
+ classification,
1042
+ sourceEventId: event.sourceEventId,
1043
+ });
1044
+ close(event.timeSeconds, event.sourceEventId, nextLead ? 'reversed' : 'equalized');
1045
+ if (nextLead)
1046
+ open = {
1047
+ leader: nextLead,
1048
+ start: event.timeSeconds,
1049
+ sourceEventId: event.sourceEventId,
1050
+ };
1051
+ previousLead = nextLead;
1052
+ if (nextLead)
1053
+ priorNonNullLead = nextLead;
1054
+ }
1055
+ }
1056
+ if (open)
1057
+ close(endSeconds, null, 'ongoing');
1058
+ }
1059
+ }
1060
+ advantageWindows.sort((left, right) => left.startedAtSeconds - right.startedAtSeconds ||
1061
+ left.teamRefs.join('\u001f').localeCompare(right.teamRefs.join('\u001f')));
1062
+ const firstKnockEvent = stateEvents.find(({ event }) => event.type === 'KNOCK');
1063
+ const firstKnock = firstKnockEvent?.event;
1064
+ const groupEvents = timeline.flatMap((timelineEvent) => {
1065
+ const event = eventById.get(timelineEvent.sourceEventId);
1066
+ return event ? [event] : [];
1067
+ });
1068
+ const response = firstKnock?.actorTeamId && firstKnock.victimTeamId && firstKnock.timeSeconds !== null
1069
+ ? groupEvents
1070
+ .filter((event) => event.sourceEventId !== firstKnock.sourceEventId &&
1071
+ event.timeSeconds !== null &&
1072
+ event.timeSeconds > firstKnock.timeSeconds &&
1073
+ event.timeSeconds <=
1074
+ firstKnock.timeSeconds + BATTLE_POST_KNOCK_RESPONSE_WINDOW_SECONDS &&
1075
+ event.actorTeamId === firstKnock.actorTeamId &&
1076
+ event.victimTeamId === firstKnock.victimTeamId &&
1077
+ (event.type === 'KNOCK' ||
1078
+ event.type === 'KILL' ||
1079
+ (event.type === 'DAMAGE' && event.hit && event.damage > 0)))
1080
+ .sort((left, right) => left.timeSeconds - right.timeSeconds ||
1081
+ left.sourceEventId.localeCompare(right.sourceEventId))[0]
1082
+ : undefined;
1083
+ const firstKnockPair = firstKnock?.actorTeamId && firstKnock.victimTeamId
1084
+ ? [
1085
+ battleTeamRef(matchId, firstKnock.actorTeamId),
1086
+ battleTeamRef(matchId, firstKnock.victimTeamId),
1087
+ ].sort()
1088
+ : [];
1089
+ const firstKnockAdvantage = firstKnock
1090
+ ? (advantageWindows.find((window) => window.startEventId === firstKnock.sourceEventId &&
1091
+ (!firstKnockPair.length ||
1092
+ window.teamRefs.every((teamRef, index) => teamRef === firstKnockPair[index]))) ?? null)
1093
+ : null;
1094
+ const firstEpisode = groupEpisodes[0];
1095
+ const firstPairTeams = new Set(firstEpisode.teams
1096
+ .map((team) => team.teamRef)
1097
+ .filter((value) => Boolean(value)));
1098
+ const possibleThirdParty = groupEpisodes.flatMap((episode) => {
1099
+ if (episode.id === firstEpisode.id ||
1100
+ episode.startSeconds > firstEpisode.endSeconds ||
1101
+ episode.endSeconds < firstEpisode.startSeconds)
1102
+ return [];
1103
+ const addedTeams = episode.teams
1104
+ .map((team) => team.teamRef)
1105
+ .filter((value) => value !== null && !firstPairTeams.has(value));
1106
+ if (!episode.teams.some((team) => team.teamRef && firstPairTeams.has(team.teamRef)))
1107
+ return [];
1108
+ return addedTeams.map((teamRef) => ({
1109
+ teamRef,
1110
+ episodeId: episode.id,
1111
+ evidenceEventIds: episodeContactEventIds(episode).slice(0, 4),
1112
+ status: 'possible_third_party_contact',
1113
+ }));
1114
+ });
1115
+ return {
1116
+ id: groupId,
1117
+ startSeconds,
1118
+ endSeconds,
1119
+ durationSeconds: Math.max(0, endSeconds - startSeconds),
1120
+ episodeIds,
1121
+ teamRefs,
1122
+ bridgeEvidenceEventIds,
1123
+ timeline,
1124
+ stateTimeline,
1125
+ advantageWindows,
1126
+ advantageChanges,
1127
+ firstKnockAdvantage,
1128
+ postKnockResponse: {
1129
+ status: response
1130
+ ? 'observed'
1131
+ : firstKnock?.actorTeamId && firstKnock.victimTeamId
1132
+ ? 'not_observed_in_window'
1133
+ : 'incomplete',
1134
+ firstKnockEventId: firstKnock?.sourceEventId ?? null,
1135
+ responseEventId: response?.sourceEventId ?? null,
1136
+ elapsedSeconds: response && firstKnock?.timeSeconds !== null && firstKnock?.timeSeconds !== undefined
1137
+ ? response.timeSeconds - firstKnock.timeSeconds
1138
+ : null,
1139
+ windowSeconds: BATTLE_POST_KNOCK_RESPONSE_WINDOW_SECONDS,
1140
+ },
1141
+ possibleThirdParty,
1142
+ rosterCompleteness: 'observed-members-only',
1143
+ };
1144
+ })
1145
+ .sort((left, right) => left.startSeconds - right.startSeconds || left.id.localeCompare(right.id));
1146
+ return groups;
1147
+ }
1148
+ export function reconstructBattleEpisodes(input) {
1149
+ const matchId = input.match.matchId;
1150
+ const events = input.telemetry.events;
1151
+ const { playerTeam, teamMembers, conflictCount: conflictingTeamReferenceCount, } = teamMembership(events);
1152
+ const segmented = segmentContacts(events, playerTeam, matchId);
1153
+ const sessions = segmented.sessions;
1154
+ const reviveAssignment = assignRevives(events, sessions);
1155
+ const contextAssignment = assignContextEvents(events, sessions, playerTeam);
1156
+ const trackedPlayerIds = new Set(input.trackedPlayerIds ?? []);
1157
+ const circleSamples = [...input.telemetry.circleSamples].sort(circleOrder);
1158
+ const transitions = circleTransitions(circleSamples.filter((sample) => sample.phase === 'in_match'), matchId);
1159
+ const stateConflictEventIds = new Set();
1160
+ const episodes = sessions.map((session) => episodeForSession(session, events, circleSamples, transitions, teamMembers, playerTeam, trackedPlayerIds, matchId, stateConflictEventIds));
1161
+ const battles = battleGroupsFor(matchId, episodes, events);
1162
+ const assignedTimelineIds = sessions.flatMap((session) => session.assigned.map((event) => event.sourceEventId));
1163
+ const countById = new Map();
1164
+ for (const id of assignedTimelineIds)
1165
+ countById.set(id, (countById.get(id) ?? 0) + 1);
1166
+ const duplicateTimelineAssignmentCount = [...countById.values()]
1167
+ .filter((count) => count > 1)
1168
+ .reduce((sum, count) => sum + count - 1, 0);
1169
+ let overlappingEpisodePairCount = 0;
1170
+ for (let left = 0; left < sessions.length; left += 1) {
1171
+ for (let right = left + 1; right < sessions.length; right += 1) {
1172
+ const a = sessions[left];
1173
+ const b = sessions[right];
1174
+ if (a.startSeconds <= b.endSeconds &&
1175
+ b.startSeconds <= a.endSeconds &&
1176
+ a.pairKey !== b.pairKey)
1177
+ overlappingEpisodePairCount += 1;
1178
+ }
1179
+ }
1180
+ const unresolvedKnockChainCount = episodes
1181
+ .flatMap((episode) => episode.knockChains)
1182
+ .filter((chain) => chain.outcome === null).length;
1183
+ const conflictingStateTransitionCount = stateConflictEventIds.size;
1184
+ const ambiguousContextEventCount = contextAssignment.ambiguous;
1185
+ const positionsSuppressedBySampling = episodes.reduce((sum, episode) => sum + episode.diagnostics.positionsSuppressedBySampling, 0);
1186
+ const circleAssignmentCounts = new Map();
1187
+ for (const episode of episodes) {
1188
+ for (const circleRef of episode.circleSampleRefs)
1189
+ circleAssignmentCounts.set(circleRef, (circleAssignmentCounts.get(circleRef) ?? 0) + 1);
1190
+ }
1191
+ const sharedCircleSampleContextCount = [...circleAssignmentCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
1192
+ const assignedContactIds = new Set(sessions.flatMap((session) => session.seedEvents.map((event) => event.sourceEventId)));
1193
+ const unassignedContactEventCount = [
1194
+ ...new Set(events
1195
+ .filter((event) => COMBAT_CONTACT_TYPES.has(event.type))
1196
+ .map((event) => event.sourceEventId)),
1197
+ ].filter((sourceEventId) => !assignedContactIds.has(sourceEventId)).length;
1198
+ return {
1199
+ schemaVersion: BATTLE_RECONSTRUCTION_SCHEMA_VERSION,
1200
+ parserVersion: BATTLE_RECONSTRUCTION_PARSER_VERSION,
1201
+ status: episodes.length ? 'available' : 'no_contact_events',
1202
+ episodes,
1203
+ battles,
1204
+ circleSamples,
1205
+ circleTransitions: transitions,
1206
+ diagnostics: {
1207
+ rawEventCount: input.telemetry.rawEventCount,
1208
+ domainNormalizedEventCount: input.telemetry.domainNormalizedEventCount,
1209
+ battleEventCount: events.length,
1210
+ contactEventCount: events.filter((event) => COMBAT_CONTACT_TYPES.has(event.type)).length,
1211
+ episodeCount: episodes.length,
1212
+ battleGroupCount: battles.length,
1213
+ multiTeamBattleCount: battles.filter((battle) => battle.teamRefs.length >= 3).length,
1214
+ groupedEpisodeCount: battles
1215
+ .filter((battle) => battle.episodeIds.length > 1)
1216
+ .reduce((sum, battle) => sum + battle.episodeIds.length, 0),
1217
+ duplicateSourceEventCount: segmented.counters.duplicateSourceEventCount,
1218
+ duplicateTimelineAssignmentCount,
1219
+ unassignedContactEventCount,
1220
+ untimedContactEventCount: segmented.counters.untimedContactEventCount,
1221
+ preMatchContactEventCount: input.telemetry.diagnostics.excludedPreMatchContactEventCount +
1222
+ segmented.counters.preMatchContactEventCount,
1223
+ noParticipantContactEventCount: segmented.counters.noParticipantContactEventCount,
1224
+ sameTeamContactEventCount: segmented.counters.sameTeamContactEventCount,
1225
+ ineffectiveContactEventCount: segmented.counters.ineffectiveContactEventCount,
1226
+ unknownPhaseContactEventCount: segmented.counters.unknownPhaseContactEventCount,
1227
+ unknownTeamContactEventCount: segmented.counters.unknownTeamContactEventCount,
1228
+ unknownPhaseCircleSampleCount: circleSamples.filter((sample) => sample.phase === 'unknown')
1229
+ .length,
1230
+ unresolvedKnockChainCount,
1231
+ reviveWithoutUniqueKnockCount: reviveAssignment.ambiguous,
1232
+ conflictingTeamReferenceCount,
1233
+ conflictingStateTransitionCount,
1234
+ ambiguousContextEventCount: contextAssignment.ambiguous,
1235
+ positionsSuppressedBySampling: contextAssignment.suppressedPositions,
1236
+ overlappingEpisodePairCount,
1237
+ sharedCircleSampleContextCount,
1238
+ },
1239
+ };
1240
+ }
1241
+ export function reconstructBattleMatch(input) {
1242
+ const telemetry = normalizeBattleTelemetry(input.telemetry, input.match);
1243
+ return reconstructBattleEpisodes({
1244
+ match: input.match,
1245
+ telemetry,
1246
+ ...(input.trackedPlayerIds === undefined ? {} : { trackedPlayerIds: input.trackedPlayerIds }),
1247
+ });
1248
+ }
1249
+ //# sourceMappingURL=battle.js.map