wows-compute 0.0.0-stage → 1.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/README.md +127 -2
- package/dist/index-CkKx_Wtz.d.mts +286 -0
- package/dist/index-_QacyXDj.d.mts +48 -0
- package/dist/index.d.mts +82 -0
- package/dist/index.mjs +670 -0
- package/dist/match-J2T9QiGg.mjs +441 -0
- package/dist/match.d.mts +3 -0
- package/dist/match.mjs +1 -0
- package/dist/metrics.d.mts +31 -0
- package/dist/metrics.mjs +159 -0
- package/dist/strength-CBihFxZD.mjs +243 -0
- package/dist/strength.d.mts +3 -0
- package/dist/strength.mjs +3 -0
- package/dist/types-DwmuyfXs.d.mts +175 -0
- package/package.json +65 -3
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
//#region src/time/index.ts
|
|
2
|
+
const GAME_DATE_PATTERN = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})$/;
|
|
3
|
+
const SECOND_MS = 1e3;
|
|
4
|
+
const DAY_MS = 864e5;
|
|
5
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
6
|
+
function formatterFor(timeZone) {
|
|
7
|
+
const cached = formatters.get(timeZone);
|
|
8
|
+
if (cached) return cached;
|
|
9
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
10
|
+
timeZone,
|
|
11
|
+
hourCycle: "h23",
|
|
12
|
+
year: "numeric",
|
|
13
|
+
month: "2-digit",
|
|
14
|
+
day: "2-digit",
|
|
15
|
+
hour: "2-digit",
|
|
16
|
+
minute: "2-digit",
|
|
17
|
+
second: "2-digit"
|
|
18
|
+
});
|
|
19
|
+
formatters.set(timeZone, formatter);
|
|
20
|
+
return formatter;
|
|
21
|
+
}
|
|
22
|
+
function localParts(timestamp, timeZone) {
|
|
23
|
+
const parts = formatterFor(timeZone).formatToParts(new Date(timestamp));
|
|
24
|
+
const values = {};
|
|
25
|
+
for (const part of parts) if (part.type !== "literal") values[part.type] = part.value;
|
|
26
|
+
return {
|
|
27
|
+
year: Number(values.year),
|
|
28
|
+
month: Number(values.month),
|
|
29
|
+
day: Number(values.day),
|
|
30
|
+
hour: Number(values.hour),
|
|
31
|
+
minute: Number(values.minute),
|
|
32
|
+
second: Number(values.second)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function utcFromParts(parts) {
|
|
36
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
37
|
+
}
|
|
38
|
+
function offsetAt(timestamp, timeZone) {
|
|
39
|
+
return utcFromParts(localParts(timestamp, timeZone)) - timestamp;
|
|
40
|
+
}
|
|
41
|
+
function localToUtc(parts, timeZone) {
|
|
42
|
+
const guess = utcFromParts(parts);
|
|
43
|
+
const firstOffset = offsetAt(guess, timeZone);
|
|
44
|
+
const firstCandidate = guess - firstOffset;
|
|
45
|
+
const secondOffset = offsetAt(firstCandidate, timeZone);
|
|
46
|
+
return secondOffset === firstOffset ? firstCandidate : guess - secondOffset;
|
|
47
|
+
}
|
|
48
|
+
function pad(value, length = 2) {
|
|
49
|
+
return String(value).padStart(length, "0");
|
|
50
|
+
}
|
|
51
|
+
function isValidParts(parts) {
|
|
52
|
+
if (parts.month < 1 || parts.month > 12 || parts.day < 1 || parts.day > 31 || parts.hour < 0 || parts.hour > 23 || parts.minute < 0 || parts.minute > 59 || parts.second < 0 || parts.second > 59) return false;
|
|
53
|
+
const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
|
54
|
+
return date.getUTCFullYear() === parts.year && date.getUTCMonth() === parts.month - 1 && date.getUTCDate() === parts.day;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parses the game's `DD.MM.YYYY HH:mm:ss` format in an explicit time zone.
|
|
58
|
+
*
|
|
59
|
+
* The value itself carries no time zone: it is the recorder's local time, so
|
|
60
|
+
* the caller must decide which zone the replay belongs to. Returns `null` for
|
|
61
|
+
* malformed or impossible dates.
|
|
62
|
+
*/
|
|
63
|
+
function parseBattleTime(value, timeZone) {
|
|
64
|
+
const match = GAME_DATE_PATTERN.exec(value);
|
|
65
|
+
if (match === null) return null;
|
|
66
|
+
const parts = {
|
|
67
|
+
day: Number(match[1]),
|
|
68
|
+
month: Number(match[2]),
|
|
69
|
+
year: Number(match[3]),
|
|
70
|
+
hour: Number(match[4]),
|
|
71
|
+
minute: Number(match[5]),
|
|
72
|
+
second: Number(match[6])
|
|
73
|
+
};
|
|
74
|
+
if (!isValidParts(parts)) return null;
|
|
75
|
+
return localToUtc(parts, timeZone);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Local "game day" label (`YYYY-MM-DD`).
|
|
79
|
+
*
|
|
80
|
+
* Days roll over at `boundaryHour` instead of midnight, so a battle at 02:00
|
|
81
|
+
* still belongs to the previous game day.
|
|
82
|
+
*/
|
|
83
|
+
function gameDay(timestamp, timeZone, boundaryHour = 4) {
|
|
84
|
+
const parts = localParts(timestamp - boundaryHour * 60 * 60 * SECOND_MS, timeZone);
|
|
85
|
+
return `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)}`;
|
|
86
|
+
}
|
|
87
|
+
/** Start of the game week (Monday at `boundaryHour`, local time) containing `timestamp`. */
|
|
88
|
+
function weekBoundary(timestamp, timeZone, boundaryHour = 4) {
|
|
89
|
+
const parts = localParts(timestamp - boundaryHour * 60 * 60 * SECOND_MS, timeZone);
|
|
90
|
+
const daysSinceMonday = (new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() + 6) % 7;
|
|
91
|
+
const monday = /* @__PURE__ */ new Date(Date.UTC(parts.year, parts.month - 1, parts.day) - daysSinceMonday * DAY_MS);
|
|
92
|
+
return localToUtc({
|
|
93
|
+
year: monday.getUTCFullYear(),
|
|
94
|
+
month: monday.getUTCMonth() + 1,
|
|
95
|
+
day: monday.getUTCDate(),
|
|
96
|
+
hour: boundaryHour,
|
|
97
|
+
minute: 0,
|
|
98
|
+
second: 0
|
|
99
|
+
}, timeZone);
|
|
100
|
+
}
|
|
101
|
+
/** Inclusive range check; omitted bounds are open. */
|
|
102
|
+
function inRange(timestamp, from, to) {
|
|
103
|
+
if (from !== void 0 && timestamp < from) return false;
|
|
104
|
+
if (to !== void 0 && timestamp > to) return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/match/index.ts
|
|
109
|
+
const QUALITY_RANK = {
|
|
110
|
+
"meta-only": 0,
|
|
111
|
+
partial: 1,
|
|
112
|
+
complete: 2
|
|
113
|
+
};
|
|
114
|
+
const DEFAULT_TIME_ZONE = "Asia/Shanghai";
|
|
115
|
+
/** Roster `dateTime` and results `startDt` differ by roughly 30–45 seconds in practice. */
|
|
116
|
+
const ROSTER_TIME_TOLERANCE_MS = 12e4;
|
|
117
|
+
/** Draw counts as a loss for both teams in every statistical view. */
|
|
118
|
+
function statOutcome(outcome) {
|
|
119
|
+
if (outcome === "win") return "win";
|
|
120
|
+
if (outcome === "loss" || outcome === "draw") return "loss";
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolves a team's outcome.
|
|
125
|
+
*
|
|
126
|
+
* `winnerTeamId` is a real team id (`0` included). Negative values mean a
|
|
127
|
+
* draw; `null` means the result packet carried no verdict.
|
|
128
|
+
*/
|
|
129
|
+
function outcomeOf(teamId, winnerTeamId) {
|
|
130
|
+
if (teamId === null || winnerTeamId === null) return "unknown";
|
|
131
|
+
if (winnerTeamId < 0) return "draw";
|
|
132
|
+
return teamId === winnerTeamId ? "win" : "loss";
|
|
133
|
+
}
|
|
134
|
+
function matchGroupOf(value) {
|
|
135
|
+
if (typeof value !== "string") return "unknown";
|
|
136
|
+
switch (value.trim().toLowerCase()) {
|
|
137
|
+
case "pvp":
|
|
138
|
+
case "random":
|
|
139
|
+
case "randombattle": return "pvp";
|
|
140
|
+
case "ranked":
|
|
141
|
+
case "rank": return "ranked";
|
|
142
|
+
case "coop":
|
|
143
|
+
case "cooperative":
|
|
144
|
+
case "pve": return "coop";
|
|
145
|
+
case "clan":
|
|
146
|
+
case "clanbattle": return "clan";
|
|
147
|
+
case "brawl": return "brawl";
|
|
148
|
+
case "event": return "event";
|
|
149
|
+
default: return "unknown";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function isBot(accountId, name) {
|
|
153
|
+
if (name !== null && name.startsWith(":")) return true;
|
|
154
|
+
return accountId !== null && accountId <= 0;
|
|
155
|
+
}
|
|
156
|
+
function relationOf(value) {
|
|
157
|
+
if (value === 0) return "self";
|
|
158
|
+
if (value === 1) return "ally";
|
|
159
|
+
if (value === 2) return "enemy";
|
|
160
|
+
return "unknown";
|
|
161
|
+
}
|
|
162
|
+
function finiteNumber(value) {
|
|
163
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
164
|
+
}
|
|
165
|
+
function positiveInteger(value) {
|
|
166
|
+
const number = finiteNumber(value);
|
|
167
|
+
return number !== null && number > 0 ? Math.trunc(number) : null;
|
|
168
|
+
}
|
|
169
|
+
function hashString(value) {
|
|
170
|
+
let hash = 2166136261;
|
|
171
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
172
|
+
hash ^= value.charCodeAt(index);
|
|
173
|
+
hash = Math.imul(hash, 16777619);
|
|
174
|
+
}
|
|
175
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
176
|
+
}
|
|
177
|
+
function provisionalId(battleTime, mapId, accountIds) {
|
|
178
|
+
const ids = [...new Set(accountIds.filter((id) => id !== null && id > 0))].sort((a, b) => a - b);
|
|
179
|
+
return `roster:${battleTime ?? "?"}:${mapId ?? "?"}:${ids.join(",")}`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Stable match key. Prefers the numeric arena id, then a deterministic derived
|
|
183
|
+
* key, then an opaque unknown marker.
|
|
184
|
+
*/
|
|
185
|
+
function arenaKeyOf(input) {
|
|
186
|
+
if (input.arenaId !== void 0 && input.arenaId !== null) return `arena:${input.arenaId}`;
|
|
187
|
+
const ids = [...new Set(input.accountIds.filter((id) => id !== null && id > 0))].sort((a, b) => a - b);
|
|
188
|
+
const derived = `${input.battleTime ?? "?"}:${input.mapId ?? "?"}:${ids.join(",")}`;
|
|
189
|
+
if (input.battleTime !== null || input.mapId !== null || ids.length > 0) return `derived:${derived}`;
|
|
190
|
+
return `unknown:${hashString(derived)}`;
|
|
191
|
+
}
|
|
192
|
+
function fromArenaInfo(input, options = {}) {
|
|
193
|
+
const timeZone = options.timeZone ?? DEFAULT_TIME_ZONE;
|
|
194
|
+
const battleTime = typeof input.dateTime === "string" ? parseBattleTime(input.dateTime, timeZone) : null;
|
|
195
|
+
const participants = (input.vehicles ?? []).map((vehicle) => {
|
|
196
|
+
const accountId = positiveInteger(vehicle.id);
|
|
197
|
+
return {
|
|
198
|
+
accountId,
|
|
199
|
+
name: typeof vehicle.name === "string" ? vehicle.name : null,
|
|
200
|
+
clanId: null,
|
|
201
|
+
clanTag: null,
|
|
202
|
+
shipId: finiteNumber(vehicle.shipId),
|
|
203
|
+
relation: relationOf(vehicle.relation),
|
|
204
|
+
isBot: isBot(accountId, typeof vehicle.name === "string" ? vehicle.name : null)
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
provisionalId: provisionalId(battleTime, finiteNumber(input.mapId), participants.map((participant) => participant.accountId)),
|
|
209
|
+
battleTime,
|
|
210
|
+
gameMode: finiteNumber(input.gameMode),
|
|
211
|
+
matchGroup: matchGroupOf(input.matchGroup),
|
|
212
|
+
mapId: finiteNumber(input.mapId),
|
|
213
|
+
mapName: typeof input.mapName === "string" ? input.mapName : null,
|
|
214
|
+
clientVersion: typeof input.clientVersionFromExe === "string" ? input.clientVersionFromExe : null,
|
|
215
|
+
durationSec: finiteNumber(input.battleDuration) ?? finiteNumber(input.duration),
|
|
216
|
+
playersPerTeam: finiteNumber(input.playersPerTeam),
|
|
217
|
+
participants,
|
|
218
|
+
raw: input
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function selfPlayerOf(facts) {
|
|
222
|
+
const results = facts.battleResults;
|
|
223
|
+
if (results === null) return null;
|
|
224
|
+
const playerId = facts.meta.playerID;
|
|
225
|
+
if (typeof playerId === "number" && playerId > 0) {
|
|
226
|
+
const byId = results.players.find((player) => player.accountId === playerId);
|
|
227
|
+
if (byId) return byId;
|
|
228
|
+
}
|
|
229
|
+
const name = facts.meta.playerName;
|
|
230
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
231
|
+
const matches = results.players.filter((player) => player.name === name);
|
|
232
|
+
return matches.length === 1 ? matches[0] : null;
|
|
233
|
+
}
|
|
234
|
+
function resultOf(player) {
|
|
235
|
+
return {
|
|
236
|
+
damage: player.damage,
|
|
237
|
+
frags: player.frags,
|
|
238
|
+
exp: player.exp,
|
|
239
|
+
rawExp: player.rawExp,
|
|
240
|
+
isAlive: player.isAlive,
|
|
241
|
+
lifeTimeSec: player.lifeTimeSec,
|
|
242
|
+
scoutingDamage: player.scoutingDamage,
|
|
243
|
+
killerAccountId: player.killerAccountId,
|
|
244
|
+
interactions: player.interactions,
|
|
245
|
+
fields: player.fields
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function divisionIdOf(fields) {
|
|
249
|
+
return positiveInteger(fields.prebattle_id);
|
|
250
|
+
}
|
|
251
|
+
function metaParticipants(vehicles) {
|
|
252
|
+
return (vehicles ?? []).map((vehicle) => {
|
|
253
|
+
const accountId = positiveInteger(vehicle.id);
|
|
254
|
+
const name = typeof vehicle.name === "string" ? vehicle.name : null;
|
|
255
|
+
return {
|
|
256
|
+
accountId,
|
|
257
|
+
name,
|
|
258
|
+
clanId: null,
|
|
259
|
+
clanTag: null,
|
|
260
|
+
shipId: finiteNumber(vehicle.shipId),
|
|
261
|
+
teamId: null,
|
|
262
|
+
relation: relationOf(vehicle.relation),
|
|
263
|
+
isBot: isBot(accountId, name),
|
|
264
|
+
divisionId: null,
|
|
265
|
+
result: null
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function fromReplay(facts, options = {}) {
|
|
270
|
+
const timeZone = options.timeZone ?? DEFAULT_TIME_ZONE;
|
|
271
|
+
const results = facts.battleResults;
|
|
272
|
+
const common = results?.common ?? null;
|
|
273
|
+
const meta = facts.meta;
|
|
274
|
+
const self = selfPlayerOf(facts);
|
|
275
|
+
const selfTeamId = self?.teamId ?? null;
|
|
276
|
+
const participants = results !== null ? results.players.map((player) => {
|
|
277
|
+
const name = player.name;
|
|
278
|
+
return {
|
|
279
|
+
accountId: player.accountId,
|
|
280
|
+
name,
|
|
281
|
+
clanId: player.clanId,
|
|
282
|
+
clanTag: player.clanTag,
|
|
283
|
+
shipId: player.shipId,
|
|
284
|
+
teamId: player.teamId,
|
|
285
|
+
relation: self !== null && player.accountId !== null && player.accountId === self.accountId ? "self" : selfTeamId !== null && player.teamId !== null ? player.teamId === selfTeamId ? "ally" : "enemy" : "unknown",
|
|
286
|
+
isBot: isBot(player.accountId, name),
|
|
287
|
+
divisionId: divisionIdOf(player.fields),
|
|
288
|
+
result: resultOf(player)
|
|
289
|
+
};
|
|
290
|
+
}) : metaParticipants(meta.vehicles);
|
|
291
|
+
const winnerTeamId = finiteNumber(common?.winnerTeamId);
|
|
292
|
+
const arenaId = results === null ? null : finiteNumber(results.arenaId);
|
|
293
|
+
const startDt = finiteNumber(common?.startDt);
|
|
294
|
+
const metaTime = typeof meta.dateTime === "string" ? parseBattleTime(meta.dateTime, timeZone) : null;
|
|
295
|
+
const battleTime = startDt !== null && startDt > 0 ? startDt * 1e3 : metaTime;
|
|
296
|
+
const mapId = finiteNumber(meta.mapId);
|
|
297
|
+
let quality = "meta-only";
|
|
298
|
+
if (results !== null) quality = participants.length > 0 && winnerTeamId !== null ? "complete" : "partial";
|
|
299
|
+
const record = {
|
|
300
|
+
arenaId: arenaId !== null ? String(arenaId) : arenaKeyOf({
|
|
301
|
+
battleTime,
|
|
302
|
+
mapId,
|
|
303
|
+
accountIds: participants.map((participant) => participant.accountId)
|
|
304
|
+
}),
|
|
305
|
+
provisionalIds: [],
|
|
306
|
+
battleTime,
|
|
307
|
+
gameMode: finiteNumber(common?.gameMode) ?? finiteNumber(meta.gameMode),
|
|
308
|
+
matchGroup: matchGroupOf(meta.matchGroup ?? meta.gameType),
|
|
309
|
+
mapId,
|
|
310
|
+
mapName: typeof meta.mapName === "string" ? meta.mapName : null,
|
|
311
|
+
clientVersion: typeof meta.clientVersionFromExe === "string" ? meta.clientVersionFromExe : null,
|
|
312
|
+
durationSec: finiteNumber(common?.durationSec) ?? finiteNumber(meta.battleDuration) ?? finiteNumber(meta.duration),
|
|
313
|
+
winnerTeamId,
|
|
314
|
+
outcome: outcomeOf(selfTeamId, winnerTeamId),
|
|
315
|
+
participants,
|
|
316
|
+
quality,
|
|
317
|
+
ingestedAt: options.ingestedAt ?? Date.now(),
|
|
318
|
+
parsedAt: options.parsedAt ?? Date.now(),
|
|
319
|
+
mappingVersion: results?.mappingVersion ?? null,
|
|
320
|
+
source: options.source ?? { kind: "replay" }
|
|
321
|
+
};
|
|
322
|
+
if (options.includeRaw && results?.raw !== void 0) record.raw = results.raw;
|
|
323
|
+
return record;
|
|
324
|
+
}
|
|
325
|
+
function participantKey(participant) {
|
|
326
|
+
if (participant.accountId !== null) return `account:${participant.accountId}`;
|
|
327
|
+
return `name:${participant.name ?? "?"}:${participant.shipId ?? "?"}`;
|
|
328
|
+
}
|
|
329
|
+
function mergeResult(base, fallback) {
|
|
330
|
+
if (base === null) return fallback;
|
|
331
|
+
if (fallback === null) return base;
|
|
332
|
+
return {
|
|
333
|
+
damage: base.damage ?? fallback.damage,
|
|
334
|
+
frags: base.frags ?? fallback.frags,
|
|
335
|
+
exp: base.exp ?? fallback.exp,
|
|
336
|
+
rawExp: base.rawExp ?? fallback.rawExp,
|
|
337
|
+
isAlive: base.isAlive ?? fallback.isAlive,
|
|
338
|
+
lifeTimeSec: base.lifeTimeSec ?? fallback.lifeTimeSec,
|
|
339
|
+
scoutingDamage: base.scoutingDamage ?? fallback.scoutingDamage,
|
|
340
|
+
killerAccountId: base.killerAccountId ?? fallback.killerAccountId,
|
|
341
|
+
interactions: {
|
|
342
|
+
...fallback.interactions,
|
|
343
|
+
...base.interactions
|
|
344
|
+
},
|
|
345
|
+
fields: {
|
|
346
|
+
...fallback.fields,
|
|
347
|
+
...base.fields
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function mergeParticipant(base, fallback) {
|
|
352
|
+
return {
|
|
353
|
+
accountId: base.accountId ?? fallback.accountId,
|
|
354
|
+
name: base.name ?? fallback.name,
|
|
355
|
+
clanId: base.clanId ?? fallback.clanId,
|
|
356
|
+
clanTag: base.clanTag ?? fallback.clanTag,
|
|
357
|
+
shipId: base.shipId ?? fallback.shipId,
|
|
358
|
+
teamId: base.teamId ?? fallback.teamId,
|
|
359
|
+
relation: base.relation !== "unknown" ? base.relation : fallback.relation,
|
|
360
|
+
isBot: base.isBot,
|
|
361
|
+
divisionId: base.divisionId ?? fallback.divisionId,
|
|
362
|
+
result: mergeResult(base.result, fallback.result)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Combines two parses of the same match.
|
|
367
|
+
*
|
|
368
|
+
* Higher quality wins; within the same quality the newer parse wins. Fields
|
|
369
|
+
* missing from the winner are filled from the other record.
|
|
370
|
+
*/
|
|
371
|
+
function mergeMatchRecords(a, b) {
|
|
372
|
+
const aRank = QUALITY_RANK[a.quality];
|
|
373
|
+
const bRank = QUALITY_RANK[b.quality];
|
|
374
|
+
const base = bRank > aRank || aRank === bRank && b.parsedAt > a.parsedAt ? b : a;
|
|
375
|
+
const fallback = base === a ? b : a;
|
|
376
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const participant of fallback.participants) byKey.set(participantKey(participant), participant);
|
|
378
|
+
const participants = base.participants.map((participant) => {
|
|
379
|
+
const key = participantKey(participant);
|
|
380
|
+
const other = byKey.get(key);
|
|
381
|
+
if (other === void 0) return participant;
|
|
382
|
+
byKey.delete(key);
|
|
383
|
+
return mergeParticipant(participant, other);
|
|
384
|
+
});
|
|
385
|
+
participants.push(...byKey.values());
|
|
386
|
+
const merged = {
|
|
387
|
+
arenaId: base.arenaId.startsWith("unknown:") ? fallback.arenaId : base.arenaId,
|
|
388
|
+
provisionalIds: [.../* @__PURE__ */ new Set([...base.provisionalIds, ...fallback.provisionalIds])],
|
|
389
|
+
battleTime: base.battleTime ?? fallback.battleTime,
|
|
390
|
+
gameMode: base.gameMode ?? fallback.gameMode,
|
|
391
|
+
matchGroup: base.matchGroup !== "unknown" ? base.matchGroup : fallback.matchGroup,
|
|
392
|
+
mapId: base.mapId ?? fallback.mapId,
|
|
393
|
+
mapName: base.mapName ?? fallback.mapName,
|
|
394
|
+
clientVersion: base.clientVersion ?? fallback.clientVersion,
|
|
395
|
+
durationSec: base.durationSec ?? fallback.durationSec,
|
|
396
|
+
winnerTeamId: base.winnerTeamId ?? fallback.winnerTeamId,
|
|
397
|
+
outcome: base.outcome !== "unknown" ? base.outcome : fallback.outcome,
|
|
398
|
+
participants,
|
|
399
|
+
quality: QUALITY_RANK[base.quality] >= QUALITY_RANK[fallback.quality] ? base.quality : fallback.quality,
|
|
400
|
+
ingestedAt: Math.min(base.ingestedAt, fallback.ingestedAt),
|
|
401
|
+
parsedAt: Math.max(base.parsedAt, fallback.parsedAt),
|
|
402
|
+
mappingVersion: base.mappingVersion ?? fallback.mappingVersion,
|
|
403
|
+
source: base.source
|
|
404
|
+
};
|
|
405
|
+
const raw = base.raw ?? fallback.raw;
|
|
406
|
+
if (raw !== void 0) merged.raw = raw;
|
|
407
|
+
return merged;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Links a pre-battle roster to a finished record.
|
|
411
|
+
*
|
|
412
|
+
* Returns `null` unless exactly one roster matches; ambiguity is never guessed.
|
|
413
|
+
*/
|
|
414
|
+
function matchRosterToRecord(rosters, record) {
|
|
415
|
+
const known = rosters.filter((roster) => record.provisionalIds.includes(roster.provisionalId));
|
|
416
|
+
if (known.length > 0) return known.length === 1 ? known[0] : null;
|
|
417
|
+
if (record.battleTime === null || record.mapId === null) return null;
|
|
418
|
+
const candidates = rosters.filter((roster) => {
|
|
419
|
+
if (roster.battleTime === null || roster.mapId !== record.mapId) return false;
|
|
420
|
+
return Math.abs(roster.battleTime - record.battleTime) <= ROSTER_TIME_TOLERANCE_MS;
|
|
421
|
+
});
|
|
422
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
423
|
+
}
|
|
424
|
+
/** Groups participants by `teamId + prebattle_id`; solo players are omitted. */
|
|
425
|
+
function groupDivisions(participants) {
|
|
426
|
+
const groups = /* @__PURE__ */ new Map();
|
|
427
|
+
for (const participant of participants) {
|
|
428
|
+
if (participant.divisionId === null || participant.divisionId <= 0) continue;
|
|
429
|
+
const key = `${participant.teamId ?? "?"}:${participant.divisionId}`;
|
|
430
|
+
const group = groups.get(key);
|
|
431
|
+
if (group === void 0) groups.set(key, {
|
|
432
|
+
teamId: participant.teamId,
|
|
433
|
+
divisionId: participant.divisionId,
|
|
434
|
+
participants: [participant]
|
|
435
|
+
});
|
|
436
|
+
else group.participants.push(participant);
|
|
437
|
+
}
|
|
438
|
+
return [...groups.values()].filter((group) => group.participants.length >= 2);
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
export { isBot as a, mergeMatchRecords as c, gameDay as d, inRange as f, groupDivisions as i, outcomeOf as l, weekBoundary as m, fromArenaInfo as n, matchGroupOf as o, parseBattleTime as p, fromReplay as r, matchRosterToRecord as s, arenaKeyOf as t, statOutcome as u };
|
package/dist/match.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { S as StatOutcome, _ as ReplayPlayerLike, a as Division, b as ResultValue, c as MatchRecord, d as PlayerResult, f as RecordQuality, g as ReplayMetaLike, h as ReplayFacts, i as ArenaRoster, l as MatchSource, m as ReplayCommonLike, n as ArenaInfoVehicle, o as MatchGroup, p as Relation, r as ArenaKeyInput, s as MatchParticipant, t as ArenaInfoLike, u as Outcome, v as ReplayResultsLike, x as RosterParticipant, y as ReplayVehicleLike } from "./types-DwmuyfXs.mjs";
|
|
2
|
+
import { a as fromReplay, c as matchGroupOf, d as outcomeOf, f as statOutcome, i as fromArenaInfo, l as matchRosterToRecord, n as FromReplayOptions, o as groupDivisions, r as arenaKeyOf, s as isBot, t as FromArenaInfoOptions, u as mergeMatchRecords } from "./index-_QacyXDj.mjs";
|
|
3
|
+
export { type ArenaInfoLike, type ArenaInfoVehicle, type ArenaKeyInput, type ArenaRoster, type Division, FromArenaInfoOptions, FromReplayOptions, type MatchGroup, type MatchParticipant, type MatchRecord, type MatchSource, type Outcome, type PlayerResult, type RecordQuality, type Relation, type ReplayCommonLike, type ReplayFacts, type ReplayMetaLike, type ReplayPlayerLike, type ReplayResultsLike, type ReplayVehicleLike, type ResultValue, type RosterParticipant, type StatOutcome, arenaKeyOf, fromArenaInfo, fromReplay, groupDivisions, isBot, matchGroupOf, matchRosterToRecord, mergeMatchRecords, outcomeOf, statOutcome };
|
package/dist/match.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as arenaKeyOf, n as fromArenaInfo, r as fromReplay, i as groupDivisions, a as isBot, o as matchGroupOf, s as matchRosterToRecord, c as mergeMatchRecords, l as outcomeOf, u as statOutcome } from "./match-J2T9QiGg.mjs";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/metrics/index.d.ts
|
|
2
|
+
export type MetricUnit = 'damage' | 'count' | 'percent' | 'seconds' | 'score';
|
|
3
|
+
export type MetricDirection = 'higher-is-better' | 'lower-is-better' | 'neutral';
|
|
4
|
+
export type MetricAggregation = 'sum' | 'mean' | 'ratio' | 'max';
|
|
5
|
+
export interface MetricSource {
|
|
6
|
+
field?: string;
|
|
7
|
+
sum?: readonly string[];
|
|
8
|
+
ratio?: readonly [string, string];
|
|
9
|
+
}
|
|
10
|
+
export interface MetricDefinition {
|
|
11
|
+
key: string;
|
|
12
|
+
labelKey: string;
|
|
13
|
+
unit: MetricUnit;
|
|
14
|
+
direction: MetricDirection;
|
|
15
|
+
aggregation: MetricAggregation;
|
|
16
|
+
source: MetricSource;
|
|
17
|
+
sinceMappingVersion?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* v1 field dictionary.
|
|
21
|
+
*
|
|
22
|
+
* Only semantic wrappers over raw result fields live here; values are not
|
|
23
|
+
* copied into new storage. Every source field must exist in the replay field
|
|
24
|
+
* mapping, which the test suite asserts.
|
|
25
|
+
*/
|
|
26
|
+
export declare const METRICS: readonly MetricDefinition[];
|
|
27
|
+
/** Derives metric values from one player's raw result fields. */
|
|
28
|
+
export declare function deriveMetrics(fields: Record<string, unknown>, metrics?: readonly MetricDefinition[]): Record<string, number | null>;
|
|
29
|
+
/** Field names referenced by a set of metric definitions. */
|
|
30
|
+
export declare function metricSourceFields(metrics?: readonly MetricDefinition[]): string[];
|
|
31
|
+
//#endregion
|
package/dist/metrics.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
//#region src/metrics/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* v1 field dictionary.
|
|
4
|
+
*
|
|
5
|
+
* Only semantic wrappers over raw result fields live here; values are not
|
|
6
|
+
* copied into new storage. Every source field must exist in the replay field
|
|
7
|
+
* mapping, which the test suite asserts.
|
|
8
|
+
*/
|
|
9
|
+
const METRICS = [
|
|
10
|
+
{
|
|
11
|
+
key: "damage",
|
|
12
|
+
labelKey: "metric.damage",
|
|
13
|
+
unit: "damage",
|
|
14
|
+
direction: "higher-is-better",
|
|
15
|
+
aggregation: "sum",
|
|
16
|
+
source: { field: "damage" }
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: "frags",
|
|
20
|
+
labelKey: "metric.frags",
|
|
21
|
+
unit: "count",
|
|
22
|
+
direction: "higher-is-better",
|
|
23
|
+
aggregation: "sum",
|
|
24
|
+
source: { field: "ships_killed" }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "scoutingDamage",
|
|
28
|
+
labelKey: "metric.scoutingDamage",
|
|
29
|
+
unit: "damage",
|
|
30
|
+
direction: "higher-is-better",
|
|
31
|
+
aggregation: "sum",
|
|
32
|
+
source: { field: "scouting_damage" }
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "potentialDamage",
|
|
36
|
+
labelKey: "metric.potentialDamage",
|
|
37
|
+
unit: "damage",
|
|
38
|
+
direction: "neutral",
|
|
39
|
+
aggregation: "sum",
|
|
40
|
+
source: { sum: [
|
|
41
|
+
"agro_air",
|
|
42
|
+
"agro_art",
|
|
43
|
+
"agro_dbomb",
|
|
44
|
+
"agro_tpd"
|
|
45
|
+
] }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: "lifeTimeSec",
|
|
49
|
+
labelKey: "metric.lifeTimeSec",
|
|
50
|
+
unit: "seconds",
|
|
51
|
+
direction: "neutral",
|
|
52
|
+
aggregation: "mean",
|
|
53
|
+
source: { field: "life_time_sec" }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: "capturePoints",
|
|
57
|
+
labelKey: "metric.capturePoints",
|
|
58
|
+
unit: "score",
|
|
59
|
+
direction: "higher-is-better",
|
|
60
|
+
aggregation: "sum",
|
|
61
|
+
source: { field: "capture_points" }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "droppedCapturePoints",
|
|
65
|
+
labelKey: "metric.droppedCapturePoints",
|
|
66
|
+
unit: "score",
|
|
67
|
+
direction: "lower-is-better",
|
|
68
|
+
aggregation: "sum",
|
|
69
|
+
source: { field: "dropped_capture_points" }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "exp",
|
|
73
|
+
labelKey: "metric.exp",
|
|
74
|
+
unit: "score",
|
|
75
|
+
direction: "higher-is-better",
|
|
76
|
+
aggregation: "sum",
|
|
77
|
+
source: { field: "exp" }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: "rawExp",
|
|
81
|
+
labelKey: "metric.rawExp",
|
|
82
|
+
unit: "score",
|
|
83
|
+
direction: "higher-is-better",
|
|
84
|
+
aggregation: "sum",
|
|
85
|
+
source: { field: "raw_exp" }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "planesKilled",
|
|
89
|
+
labelKey: "metric.planesKilled",
|
|
90
|
+
unit: "count",
|
|
91
|
+
direction: "higher-is-better",
|
|
92
|
+
aggregation: "sum",
|
|
93
|
+
source: { field: "planes_killed_by_ship" }
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: "mainBatteryHits",
|
|
97
|
+
labelKey: "metric.mainBatteryHits",
|
|
98
|
+
unit: "count",
|
|
99
|
+
direction: "higher-is-better",
|
|
100
|
+
aggregation: "sum",
|
|
101
|
+
source: { sum: [
|
|
102
|
+
"hits_main_he",
|
|
103
|
+
"hits_main_ap",
|
|
104
|
+
"hits_main_cs"
|
|
105
|
+
] }
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "torpedoHits",
|
|
109
|
+
labelKey: "metric.torpedoHits",
|
|
110
|
+
unit: "count",
|
|
111
|
+
direction: "higher-is-better",
|
|
112
|
+
aggregation: "sum",
|
|
113
|
+
source: { field: "hits_tpd" }
|
|
114
|
+
}
|
|
115
|
+
];
|
|
116
|
+
function numeric(value) {
|
|
117
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
118
|
+
}
|
|
119
|
+
function resolveMetric(fields, definition) {
|
|
120
|
+
const { source } = definition;
|
|
121
|
+
if (source.field !== void 0) return numeric(fields[source.field]);
|
|
122
|
+
if (source.sum !== void 0) {
|
|
123
|
+
let total = 0;
|
|
124
|
+
let found = false;
|
|
125
|
+
for (const field of source.sum) {
|
|
126
|
+
const value = numeric(fields[field]);
|
|
127
|
+
if (value === null) continue;
|
|
128
|
+
total += value;
|
|
129
|
+
found = true;
|
|
130
|
+
}
|
|
131
|
+
return found ? total : null;
|
|
132
|
+
}
|
|
133
|
+
if (source.ratio !== void 0) {
|
|
134
|
+
const numerator = numeric(fields[source.ratio[0]]);
|
|
135
|
+
const denominator = numeric(fields[source.ratio[1]]);
|
|
136
|
+
if (numerator === null || denominator === null || denominator === 0) return null;
|
|
137
|
+
return numerator / denominator;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/** Derives metric values from one player's raw result fields. */
|
|
142
|
+
function deriveMetrics(fields, metrics = METRICS) {
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const definition of metrics) result[definition.key] = resolveMetric(fields, definition);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
/** Field names referenced by a set of metric definitions. */
|
|
148
|
+
function metricSourceFields(metrics = METRICS) {
|
|
149
|
+
const fields = /* @__PURE__ */ new Set();
|
|
150
|
+
for (const definition of metrics) {
|
|
151
|
+
const { source } = definition;
|
|
152
|
+
if (source.field !== void 0) fields.add(source.field);
|
|
153
|
+
if (source.sum !== void 0) for (const field of source.sum) fields.add(field);
|
|
154
|
+
if (source.ratio !== void 0) for (const field of source.ratio) fields.add(field);
|
|
155
|
+
}
|
|
156
|
+
return [...fields];
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
export { METRICS, deriveMetrics, metricSourceFields };
|