@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.
- package/LICENSE +21 -0
- package/dist/src/analytics.d.ts +176 -0
- package/dist/src/analytics.js +431 -0
- package/dist/src/analytics.js.map +1 -0
- package/dist/src/assistant.d.ts +87 -0
- package/dist/src/assistant.js +310 -0
- package/dist/src/assistant.js.map +1 -0
- package/dist/src/battle.d.ts +280 -0
- package/dist/src/battle.js +1249 -0
- package/dist/src/battle.js.map +1 -0
- package/dist/src/combat-long.d.ts +42 -0
- package/dist/src/combat-long.js +146 -0
- package/dist/src/combat-long.js.map +1 -0
- package/dist/src/combat-quality.d.ts +101 -0
- package/dist/src/combat-quality.js +253 -0
- package/dist/src/combat-quality.js.map +1 -0
- package/dist/src/evaluation-fixtures.d.ts +13 -0
- package/dist/src/evaluation-fixtures.js +144 -0
- package/dist/src/evaluation-fixtures.js.map +1 -0
- package/dist/src/evaluation-replay.d.ts +5 -0
- package/dist/src/evaluation-replay.js +107 -0
- package/dist/src/evaluation-replay.js.map +1 -0
- package/dist/src/evaluation.d.ts +140 -0
- package/dist/src/evaluation.js +180 -0
- package/dist/src/evaluation.js.map +1 -0
- package/dist/src/experiment-store.d.ts +56 -0
- package/dist/src/experiment-store.js +159 -0
- package/dist/src/experiment-store.js.map +1 -0
- package/dist/src/field.d.ts +103 -0
- package/dist/src/field.js +298 -0
- package/dist/src/field.js.map +1 -0
- package/dist/src/fights.d.ts +89 -0
- package/dist/src/fights.js +238 -0
- package/dist/src/fights.js.map +1 -0
- package/dist/src/highlights.d.ts +44 -0
- package/dist/src/highlights.js +153 -0
- package/dist/src/highlights.js.map +1 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/insights.d.ts +44 -0
- package/dist/src/insights.js +343 -0
- package/dist/src/insights.js.map +1 -0
- package/dist/src/intent.d.ts +40 -0
- package/dist/src/intent.js +119 -0
- package/dist/src/intent.js.map +1 -0
- package/dist/src/landing.d.ts +74 -0
- package/dist/src/landing.js +195 -0
- package/dist/src/landing.js.map +1 -0
- package/dist/src/lobby.d.ts +48 -0
- package/dist/src/lobby.js +209 -0
- package/dist/src/lobby.js.map +1 -0
- package/dist/src/match-insight.d.ts +165 -0
- package/dist/src/match-insight.js +542 -0
- package/dist/src/match-insight.js.map +1 -0
- package/dist/src/match-relative.d.ts +73 -0
- package/dist/src/match-relative.js +145 -0
- package/dist/src/match-relative.js.map +1 -0
- package/dist/src/match-story.d.ts +155 -0
- package/dist/src/match-story.js +636 -0
- package/dist/src/match-story.js.map +1 -0
- package/dist/src/profiles.d.ts +41 -0
- package/dist/src/profiles.js +136 -0
- package/dist/src/profiles.js.map +1 -0
- package/dist/src/raw-telemetry-archive.d.ts +29 -0
- package/dist/src/raw-telemetry-archive.js +340 -0
- package/dist/src/raw-telemetry-archive.js.map +1 -0
- package/dist/src/raw-telemetry-backfill.d.ts +28 -0
- package/dist/src/raw-telemetry-backfill.js +144 -0
- package/dist/src/raw-telemetry-backfill.js.map +1 -0
- package/dist/src/research.d.ts +89 -0
- package/dist/src/research.js +376 -0
- package/dist/src/research.js.map +1 -0
- package/dist/src/rhythm.d.ts +104 -0
- package/dist/src/rhythm.js +260 -0
- package/dist/src/rhythm.js.map +1 -0
- package/dist/src/streaks.d.ts +117 -0
- package/dist/src/streaks.js +367 -0
- package/dist/src/streaks.js.map +1 -0
- package/dist/src/style.d.ts +304 -0
- package/dist/src/style.js +1461 -0
- package/dist/src/style.js.map +1 -0
- package/dist/src/tactics.d.ts +88 -0
- package/dist/src/tactics.js +1221 -0
- package/dist/src/tactics.js.map +1 -0
- package/package.json +49 -3
- 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
|