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