wows-compute 0.0.0-stage → 1.0.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 +110 -3
- package/dist/index-DoUlnTgG.d.mts +157 -0
- package/dist/index-_QacyXDj.d.mts +48 -0
- package/dist/index.d.mts +82 -0
- package/dist/index.mjs +669 -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.d.mts +80 -0
- package/dist/strength.mjs +154 -0
- package/dist/types-DwmuyfXs.d.mts +175 -0
- package/package.json +65 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { d as gameDay, f as inRange, l as outcomeOf, m as weekBoundary, p as parseBattleTime, u as statOutcome } from "./match-J2T9QiGg.mjs";
|
|
2
|
+
import { PR_BANDS, PR_BANDS as PR_BANDS$1, PR_CATEGORY_LABELS, WIN_RATE_BANDS, WIN_RATE_BANDS as WIN_RATE_BANDS$1, calculateBattlePR, calculatePR, prCategory, prCategory as prCategory$1, shipExpectedValues, winRateCategory, winRateCategory as winRateCategory$1 } from "wows-number-api-sdk";
|
|
3
|
+
//#region src/rating/index.ts
|
|
4
|
+
const ACCOUNT_SHIP_ID = "__account__";
|
|
5
|
+
function baselineOf(expected) {
|
|
6
|
+
return String(expected.time);
|
|
7
|
+
}
|
|
8
|
+
function resultValue(result) {
|
|
9
|
+
return result === null ? null : result.pr;
|
|
10
|
+
}
|
|
11
|
+
/** Unweighted mean of every ship that carries expected values. */
|
|
12
|
+
function globalAverages(expected) {
|
|
13
|
+
let damage = 0;
|
|
14
|
+
let frags = 0;
|
|
15
|
+
let winRate = 0;
|
|
16
|
+
let count = 0;
|
|
17
|
+
for (const entry of Object.values(expected.data)) {
|
|
18
|
+
if (Array.isArray(entry)) continue;
|
|
19
|
+
damage += entry.average_damage_dealt;
|
|
20
|
+
frags += entry.average_frags;
|
|
21
|
+
winRate += entry.win_rate;
|
|
22
|
+
count += 1;
|
|
23
|
+
}
|
|
24
|
+
if (count === 0) return null;
|
|
25
|
+
return {
|
|
26
|
+
damage: damage / count,
|
|
27
|
+
frags: frags / count,
|
|
28
|
+
winRate: winRate / count
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function estimateBias(expected, averages, shipMix) {
|
|
32
|
+
if (shipMix === void 0 || shipMix.length === 0) return {
|
|
33
|
+
direction: "unknown",
|
|
34
|
+
reason: "insufficient-data"
|
|
35
|
+
};
|
|
36
|
+
let battles = 0;
|
|
37
|
+
let expectedDamage = 0;
|
|
38
|
+
let expectedFrags = 0;
|
|
39
|
+
let expectedWins = 0;
|
|
40
|
+
for (const entry of shipMix) {
|
|
41
|
+
if (!Number.isFinite(entry.battles) || entry.battles <= 0) continue;
|
|
42
|
+
const values = shipExpectedValues(expected, entry.shipId);
|
|
43
|
+
if (values === null) continue;
|
|
44
|
+
battles += entry.battles;
|
|
45
|
+
expectedDamage += values.average_damage_dealt * entry.battles;
|
|
46
|
+
expectedFrags += values.average_frags * entry.battles;
|
|
47
|
+
expectedWins += values.win_rate / 100 * entry.battles;
|
|
48
|
+
}
|
|
49
|
+
if (battles === 0) return {
|
|
50
|
+
direction: "unknown",
|
|
51
|
+
reason: "insufficient-data"
|
|
52
|
+
};
|
|
53
|
+
const delta = {
|
|
54
|
+
damage: expectedDamage / battles / averages.damage - 1,
|
|
55
|
+
frags: expectedFrags / battles / averages.frags - 1,
|
|
56
|
+
wins: expectedWins / battles / (averages.winRate / 100) - 1
|
|
57
|
+
};
|
|
58
|
+
const directions = [
|
|
59
|
+
delta.damage,
|
|
60
|
+
delta.frags,
|
|
61
|
+
delta.wins
|
|
62
|
+
];
|
|
63
|
+
const positives = directions.filter((value) => value > 1e-9).length;
|
|
64
|
+
const negatives = directions.filter((value) => value < -1e-9).length;
|
|
65
|
+
let direction = "unknown";
|
|
66
|
+
if (positives === directions.length) direction = "overestimate";
|
|
67
|
+
else if (negatives === directions.length) direction = "underestimate";
|
|
68
|
+
else if (positives > 0 && negatives > 0) direction = "mixed";
|
|
69
|
+
return {
|
|
70
|
+
direction,
|
|
71
|
+
reason: "ship-mix",
|
|
72
|
+
delta
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Personal Rating for one ship, using that ship's own expected values. */
|
|
76
|
+
function shipPr(stats, expected, sample = {
|
|
77
|
+
kind: "lifetime",
|
|
78
|
+
matches: stats.battles
|
|
79
|
+
}) {
|
|
80
|
+
const value = resultValue(calculatePR([stats], expected));
|
|
81
|
+
if (value === null) return null;
|
|
82
|
+
return {
|
|
83
|
+
value,
|
|
84
|
+
basis: "ship",
|
|
85
|
+
exact: true,
|
|
86
|
+
sample,
|
|
87
|
+
baselineVersion: baselineOf(expected)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Personal Rating for a single battle. */
|
|
91
|
+
function battlePr(battle, expected, sample = {
|
|
92
|
+
kind: "match",
|
|
93
|
+
matches: 1
|
|
94
|
+
}) {
|
|
95
|
+
const value = resultValue(calculateBattlePR(battle, expected));
|
|
96
|
+
if (value === null) return null;
|
|
97
|
+
return {
|
|
98
|
+
value,
|
|
99
|
+
basis: "ship",
|
|
100
|
+
exact: true,
|
|
101
|
+
sample,
|
|
102
|
+
baselineVersion: baselineOf(expected)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Fast account PR*: account totals against the unweighted average of all
|
|
107
|
+
* expected values.
|
|
108
|
+
*
|
|
109
|
+
* This is an estimate, not a different formula: the account is treated as one
|
|
110
|
+
* synthetic ship. `exact: false` is a product promise — UI must mark it `PR*`.
|
|
111
|
+
*/
|
|
112
|
+
function accountPrEstimated(account, expected, options = {}) {
|
|
113
|
+
if (!Number.isFinite(account.battles) || account.battles <= 0) return null;
|
|
114
|
+
const averages = globalAverages(expected);
|
|
115
|
+
if (averages === null) return null;
|
|
116
|
+
const syntheticExpected = {
|
|
117
|
+
time: expected.time,
|
|
118
|
+
data: { [ACCOUNT_SHIP_ID]: {
|
|
119
|
+
average_damage_dealt: averages.damage,
|
|
120
|
+
average_frags: averages.frags,
|
|
121
|
+
win_rate: averages.winRate
|
|
122
|
+
} }
|
|
123
|
+
};
|
|
124
|
+
const synthetic = {
|
|
125
|
+
shipId: ACCOUNT_SHIP_ID,
|
|
126
|
+
battles: account.battles,
|
|
127
|
+
damage: account.damage,
|
|
128
|
+
frags: account.frags,
|
|
129
|
+
wins: account.wins
|
|
130
|
+
};
|
|
131
|
+
const value = resultValue(calculatePR([synthetic], syntheticExpected));
|
|
132
|
+
if (value === null) return null;
|
|
133
|
+
return {
|
|
134
|
+
value,
|
|
135
|
+
basis: "account",
|
|
136
|
+
exact: false,
|
|
137
|
+
sample: {
|
|
138
|
+
kind: "lifetime",
|
|
139
|
+
matches: account.battles
|
|
140
|
+
},
|
|
141
|
+
baselineVersion: baselineOf(expected),
|
|
142
|
+
bias: estimateBias(expected, averages, options.shipMix)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Exact weighted account PR: pools per-ship totals and per-ship baselines.
|
|
147
|
+
*
|
|
148
|
+
* Ships without expected values are skipped by `calculatePR`, not counted
|
|
149
|
+
* as zero.
|
|
150
|
+
*/
|
|
151
|
+
function accountPrPooled(ships, expected, sample) {
|
|
152
|
+
const value = resultValue(calculatePR(ships, expected));
|
|
153
|
+
if (value === null) return null;
|
|
154
|
+
return {
|
|
155
|
+
value,
|
|
156
|
+
basis: "account",
|
|
157
|
+
exact: true,
|
|
158
|
+
sample,
|
|
159
|
+
baselineVersion: baselineOf(expected)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/aggregate/index.ts
|
|
164
|
+
function emptyCoverage() {
|
|
165
|
+
return {
|
|
166
|
+
total: 0,
|
|
167
|
+
resolved: 0,
|
|
168
|
+
hidden: 0,
|
|
169
|
+
bot: 0,
|
|
170
|
+
failed: 0
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function includedRecord(record, options) {
|
|
174
|
+
if (options.matchGroups !== void 0 && !options.matchGroups.includes(record.matchGroup)) return false;
|
|
175
|
+
if (record.battleTime === null) return options.from === void 0 && options.to === void 0;
|
|
176
|
+
if (options.from !== void 0 && record.battleTime < options.from) return false;
|
|
177
|
+
if (options.to !== void 0 && record.battleTime > options.to) return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function sampleOf(matches, players, options) {
|
|
181
|
+
const sample = {
|
|
182
|
+
kind: "ingested",
|
|
183
|
+
matches,
|
|
184
|
+
players
|
|
185
|
+
};
|
|
186
|
+
if (options.from !== void 0) sample.from = new Date(options.from).toISOString();
|
|
187
|
+
if (options.to !== void 0) sample.to = new Date(options.to).toISOString();
|
|
188
|
+
return sample;
|
|
189
|
+
}
|
|
190
|
+
function addShip(map, shipId, damage, frags, win) {
|
|
191
|
+
const key = String(shipId);
|
|
192
|
+
const current = map.get(key);
|
|
193
|
+
if (current === void 0) map.set(key, {
|
|
194
|
+
shipId,
|
|
195
|
+
battles: 1,
|
|
196
|
+
damage,
|
|
197
|
+
frags,
|
|
198
|
+
wins: win ? 1 : 0
|
|
199
|
+
});
|
|
200
|
+
else {
|
|
201
|
+
current.battles += 1;
|
|
202
|
+
current.damage += damage;
|
|
203
|
+
current.frags += frags;
|
|
204
|
+
if (win) current.wins += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function scan(records, options, teamId) {
|
|
208
|
+
const coverage = emptyCoverage();
|
|
209
|
+
const players = /* @__PURE__ */ new Map();
|
|
210
|
+
const ships = /* @__PURE__ */ new Map();
|
|
211
|
+
let matches = 0;
|
|
212
|
+
for (const record of records) {
|
|
213
|
+
if (!includedRecord(record, options)) continue;
|
|
214
|
+
const participants = teamId === void 0 ? record.participants : record.participants.filter((participant) => participant.teamId === teamId);
|
|
215
|
+
if (participants.length === 0) continue;
|
|
216
|
+
matches += 1;
|
|
217
|
+
for (const participant of participants) {
|
|
218
|
+
coverage.total += 1;
|
|
219
|
+
if (participant.isBot) {
|
|
220
|
+
coverage.bot += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (participant.accountId === null) {
|
|
224
|
+
coverage.hidden += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (participant.result === null) {
|
|
228
|
+
coverage.failed += 1;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const outcome = outcomeOf(participant.teamId, record.winnerTeamId);
|
|
232
|
+
const statistical = statOutcome(outcome);
|
|
233
|
+
if (statistical === null) {
|
|
234
|
+
coverage.failed += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
coverage.resolved += 1;
|
|
238
|
+
const accountId = participant.accountId;
|
|
239
|
+
const damage = participant.result.damage ?? 0;
|
|
240
|
+
const frags = participant.result.frags ?? 0;
|
|
241
|
+
const win = statistical === "win";
|
|
242
|
+
let player = players.get(accountId);
|
|
243
|
+
if (player === void 0) {
|
|
244
|
+
player = {
|
|
245
|
+
accountId,
|
|
246
|
+
name: participant.name,
|
|
247
|
+
battles: 0,
|
|
248
|
+
wins: 0,
|
|
249
|
+
losses: 0,
|
|
250
|
+
draws: 0,
|
|
251
|
+
damage: 0,
|
|
252
|
+
frags: 0,
|
|
253
|
+
matches: 0,
|
|
254
|
+
ships: /* @__PURE__ */ new Map()
|
|
255
|
+
};
|
|
256
|
+
players.set(accountId, player);
|
|
257
|
+
}
|
|
258
|
+
player.matches += 1;
|
|
259
|
+
player.battles += 1;
|
|
260
|
+
if (outcome === "win") player.wins += 1;
|
|
261
|
+
else if (outcome === "loss") player.losses += 1;
|
|
262
|
+
else if (outcome === "draw") player.draws += 1;
|
|
263
|
+
player.damage += damage;
|
|
264
|
+
player.frags += frags;
|
|
265
|
+
if (participant.shipId !== null) {
|
|
266
|
+
addShip(player.ships, participant.shipId, damage, frags, win);
|
|
267
|
+
const ship = ships.get(participant.shipId);
|
|
268
|
+
if (ship === void 0) ships.set(participant.shipId, {
|
|
269
|
+
shipId: participant.shipId,
|
|
270
|
+
battles: 1,
|
|
271
|
+
wins: win ? 1 : 0,
|
|
272
|
+
losses: outcome === "loss" ? 1 : 0,
|
|
273
|
+
draws: outcome === "draw" ? 1 : 0,
|
|
274
|
+
damage,
|
|
275
|
+
frags,
|
|
276
|
+
players: /* @__PURE__ */ new Set([accountId])
|
|
277
|
+
});
|
|
278
|
+
else {
|
|
279
|
+
ship.battles += 1;
|
|
280
|
+
if (outcome === "win") ship.wins += 1;
|
|
281
|
+
else if (outcome === "loss") ship.losses += 1;
|
|
282
|
+
else ship.draws += 1;
|
|
283
|
+
ship.damage += damage;
|
|
284
|
+
ship.frags += frags;
|
|
285
|
+
ship.players.add(accountId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
matches,
|
|
292
|
+
coverage,
|
|
293
|
+
players,
|
|
294
|
+
ships
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function ratioOf(numerator, denominator) {
|
|
298
|
+
return denominator === 0 ? null : numerator / denominator;
|
|
299
|
+
}
|
|
300
|
+
function weightedMean(entries, weighting) {
|
|
301
|
+
let total = 0;
|
|
302
|
+
let weight = 0;
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
const entryWeight = weighting === "equal" ? 1 : entry.weight;
|
|
305
|
+
if (entryWeight <= 0) continue;
|
|
306
|
+
total += entry.value * entryWeight;
|
|
307
|
+
weight += entryWeight;
|
|
308
|
+
}
|
|
309
|
+
return weight === 0 ? null : total / weight;
|
|
310
|
+
}
|
|
311
|
+
function playerPr(player, options, sample) {
|
|
312
|
+
if (options.expected === void 0 || player.ships.size === 0) return null;
|
|
313
|
+
return accountPrPooled([...player.ships.values()], options.expected, sample)?.value ?? null;
|
|
314
|
+
}
|
|
315
|
+
function playerCoverage(player) {
|
|
316
|
+
return {
|
|
317
|
+
total: player.battles,
|
|
318
|
+
resolved: player.battles,
|
|
319
|
+
hidden: 0,
|
|
320
|
+
bot: 0,
|
|
321
|
+
failed: 0
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function playerValues(player, options, sample) {
|
|
325
|
+
const coverage = playerCoverage(player);
|
|
326
|
+
return {
|
|
327
|
+
accountId: player.accountId,
|
|
328
|
+
name: player.name,
|
|
329
|
+
battles: player.battles,
|
|
330
|
+
wins: player.wins,
|
|
331
|
+
losses: player.losses,
|
|
332
|
+
draws: player.draws,
|
|
333
|
+
damage: player.damage,
|
|
334
|
+
frags: player.frags,
|
|
335
|
+
winRate: {
|
|
336
|
+
value: ratioOf(player.wins * 100, player.battles),
|
|
337
|
+
sample,
|
|
338
|
+
coverage
|
|
339
|
+
},
|
|
340
|
+
avgDamage: {
|
|
341
|
+
value: ratioOf(player.damage, player.battles),
|
|
342
|
+
sample,
|
|
343
|
+
coverage
|
|
344
|
+
},
|
|
345
|
+
avgFrags: {
|
|
346
|
+
value: ratioOf(player.frags, player.battles),
|
|
347
|
+
sample,
|
|
348
|
+
coverage
|
|
349
|
+
},
|
|
350
|
+
pr: {
|
|
351
|
+
value: playerPr(player, options, sample),
|
|
352
|
+
sample,
|
|
353
|
+
coverage
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/** Per-player aggregates over the selected records. Ordered by battles, then account id. */
|
|
358
|
+
function aggregatePlayers(records, options) {
|
|
359
|
+
return [...scan(records, options).players.values()].map((player) => playerValues(player, options, sampleOf(player.matches, 1, options))).sort((a, b) => b.battles - a.battles || a.accountId - b.accountId);
|
|
360
|
+
}
|
|
361
|
+
/** One team's aggregate; coverage only counts that team's participants. */
|
|
362
|
+
function aggregateTeam(records, teamId, options) {
|
|
363
|
+
const scanned = teamId === null ? scan(records, options) : scan(records, options, teamId);
|
|
364
|
+
const players = [...scanned.players.values()];
|
|
365
|
+
const sample = sampleOf(scanned.matches, players.length, options);
|
|
366
|
+
const coverage = scanned.coverage;
|
|
367
|
+
const winRate = weightedMean(players.filter((player) => player.battles > 0).map((player) => ({
|
|
368
|
+
value: player.wins / player.battles * 100,
|
|
369
|
+
weight: player.battles
|
|
370
|
+
})), options.weighting);
|
|
371
|
+
const avgDamage = weightedMean(players.filter((player) => player.battles > 0).map((player) => ({
|
|
372
|
+
value: player.damage / player.battles,
|
|
373
|
+
weight: player.battles
|
|
374
|
+
})), options.weighting);
|
|
375
|
+
const avgFrags = weightedMean(players.filter((player) => player.battles > 0).map((player) => ({
|
|
376
|
+
value: player.frags / player.battles,
|
|
377
|
+
weight: player.battles
|
|
378
|
+
})), options.weighting);
|
|
379
|
+
let pr = null;
|
|
380
|
+
if (options.expected !== void 0) {
|
|
381
|
+
if (options.prAggregation === "pooled") {
|
|
382
|
+
const ships = players.flatMap((player) => [...player.ships.values()]);
|
|
383
|
+
pr = ships.length === 0 ? null : accountPrPooled(ships, options.expected, sample)?.value ?? null;
|
|
384
|
+
} else pr = weightedMean(players.map((player) => ({
|
|
385
|
+
value: playerPr(player, options, sample),
|
|
386
|
+
weight: player.battles
|
|
387
|
+
})).filter((entry) => entry.value !== null), options.weighting);
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
teamId,
|
|
391
|
+
players: players.length,
|
|
392
|
+
coverage,
|
|
393
|
+
values: {
|
|
394
|
+
winRate: {
|
|
395
|
+
value: winRate,
|
|
396
|
+
sample,
|
|
397
|
+
coverage
|
|
398
|
+
},
|
|
399
|
+
avgDamage: {
|
|
400
|
+
value: avgDamage,
|
|
401
|
+
sample,
|
|
402
|
+
coverage
|
|
403
|
+
},
|
|
404
|
+
avgFrags: {
|
|
405
|
+
value: avgFrags,
|
|
406
|
+
sample,
|
|
407
|
+
coverage
|
|
408
|
+
},
|
|
409
|
+
pr: {
|
|
410
|
+
value: pr,
|
|
411
|
+
sample,
|
|
412
|
+
coverage
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Per-ship aggregates over the selected records. Ordered by battles, then ship id. */
|
|
418
|
+
function aggregateShips(records, options) {
|
|
419
|
+
const scanned = scan(records, options);
|
|
420
|
+
const coverage = scanned.coverage;
|
|
421
|
+
const rows = [];
|
|
422
|
+
for (const ship of scanned.ships.values()) {
|
|
423
|
+
const sample = sampleOf(scanned.matches, ship.players.size, options);
|
|
424
|
+
const stats = {
|
|
425
|
+
shipId: ship.shipId,
|
|
426
|
+
battles: ship.battles,
|
|
427
|
+
damage: ship.damage,
|
|
428
|
+
frags: ship.frags,
|
|
429
|
+
wins: ship.wins
|
|
430
|
+
};
|
|
431
|
+
const pr = options.expected === void 0 ? null : accountPrPooled([stats], options.expected, sample)?.value ?? null;
|
|
432
|
+
rows.push({
|
|
433
|
+
shipId: ship.shipId,
|
|
434
|
+
battles: ship.battles,
|
|
435
|
+
wins: ship.wins,
|
|
436
|
+
losses: ship.losses,
|
|
437
|
+
draws: ship.draws,
|
|
438
|
+
damage: ship.damage,
|
|
439
|
+
frags: ship.frags,
|
|
440
|
+
winRate: {
|
|
441
|
+
value: ratioOf(ship.wins * 100, ship.battles),
|
|
442
|
+
sample,
|
|
443
|
+
coverage
|
|
444
|
+
},
|
|
445
|
+
avgDamage: {
|
|
446
|
+
value: ratioOf(ship.damage, ship.battles),
|
|
447
|
+
sample,
|
|
448
|
+
coverage
|
|
449
|
+
},
|
|
450
|
+
avgFrags: {
|
|
451
|
+
value: ratioOf(ship.frags, ship.battles),
|
|
452
|
+
sample,
|
|
453
|
+
coverage
|
|
454
|
+
},
|
|
455
|
+
pr: {
|
|
456
|
+
value: pr,
|
|
457
|
+
sample,
|
|
458
|
+
coverage
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return rows.sort((a, b) => b.battles - a.battles || a.shipId - b.shipId);
|
|
463
|
+
}
|
|
464
|
+
/** Self performance per game day, ordered oldest first. */
|
|
465
|
+
function dailySeries(records, options) {
|
|
466
|
+
const days = /* @__PURE__ */ new Map();
|
|
467
|
+
for (const record of records) {
|
|
468
|
+
if (record.battleTime === null) continue;
|
|
469
|
+
if (options.matchGroups !== void 0 && !options.matchGroups.includes(record.matchGroup)) continue;
|
|
470
|
+
if (options.from !== void 0 && record.battleTime < options.from) continue;
|
|
471
|
+
if (options.to !== void 0 && record.battleTime > options.to) continue;
|
|
472
|
+
const self = record.participants.find((participant) => participant.relation === "self");
|
|
473
|
+
if (self === void 0 || self.result === null) continue;
|
|
474
|
+
const outcome = outcomeOf(self.teamId, record.winnerTeamId);
|
|
475
|
+
if (statOutcome(outcome) === null) continue;
|
|
476
|
+
const day = gameDay(record.battleTime, options.timeZone, options.boundaryHour);
|
|
477
|
+
let totals = days.get(day);
|
|
478
|
+
if (totals === void 0) {
|
|
479
|
+
totals = {
|
|
480
|
+
day,
|
|
481
|
+
matches: 0,
|
|
482
|
+
wins: 0,
|
|
483
|
+
losses: 0,
|
|
484
|
+
draws: 0,
|
|
485
|
+
damage: 0,
|
|
486
|
+
frags: 0,
|
|
487
|
+
ships: /* @__PURE__ */ new Map()
|
|
488
|
+
};
|
|
489
|
+
days.set(day, totals);
|
|
490
|
+
}
|
|
491
|
+
totals.matches += 1;
|
|
492
|
+
if (outcome === "win") totals.wins += 1;
|
|
493
|
+
else if (outcome === "loss") totals.losses += 1;
|
|
494
|
+
else totals.draws += 1;
|
|
495
|
+
const damage = self.result.damage ?? 0;
|
|
496
|
+
const frags = self.result.frags ?? 0;
|
|
497
|
+
totals.damage += damage;
|
|
498
|
+
totals.frags += frags;
|
|
499
|
+
if (self.shipId !== null) addShip(totals.ships, self.shipId, damage, frags, outcome === "win");
|
|
500
|
+
}
|
|
501
|
+
const points = [];
|
|
502
|
+
for (const totals of days.values()) {
|
|
503
|
+
const battles = totals.matches;
|
|
504
|
+
const coverage = {
|
|
505
|
+
total: battles,
|
|
506
|
+
resolved: battles,
|
|
507
|
+
hidden: 0,
|
|
508
|
+
bot: 0,
|
|
509
|
+
failed: 0
|
|
510
|
+
};
|
|
511
|
+
const sample = {
|
|
512
|
+
kind: "ingested",
|
|
513
|
+
matches: battles
|
|
514
|
+
};
|
|
515
|
+
const pr = options.expected === void 0 || totals.ships.size === 0 ? null : accountPrPooled([...totals.ships.values()], options.expected, sample)?.value ?? null;
|
|
516
|
+
points.push({
|
|
517
|
+
day: totals.day,
|
|
518
|
+
matches: totals.matches,
|
|
519
|
+
battles,
|
|
520
|
+
wins: totals.wins,
|
|
521
|
+
losses: totals.losses,
|
|
522
|
+
draws: totals.draws,
|
|
523
|
+
winRate: battles === 0 ? null : totals.wins / battles * 100,
|
|
524
|
+
avgDamage: battles === 0 ? null : totals.damage / battles,
|
|
525
|
+
avgFrags: battles === 0 ? null : totals.frags / battles,
|
|
526
|
+
pr: {
|
|
527
|
+
value: pr,
|
|
528
|
+
sample,
|
|
529
|
+
coverage
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return points.sort((a, b) => a.day.localeCompare(b.day));
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/levels/index.ts
|
|
537
|
+
/** Level key plus thresholds. Labels and colors belong to the application. */
|
|
538
|
+
function levelOf(kind, value) {
|
|
539
|
+
if (!Number.isFinite(value)) return null;
|
|
540
|
+
const bands = kind === "pr" ? PR_BANDS$1 : WIN_RATE_BANDS$1;
|
|
541
|
+
const levelKey = kind === "pr" ? prCategory$1(value) : winRateCategory$1(value);
|
|
542
|
+
const index = bands.findIndex((band) => band.category === levelKey);
|
|
543
|
+
const band = bands[index];
|
|
544
|
+
if (band === void 0) return null;
|
|
545
|
+
const nextFloor = bands[index + 1]?.floor ?? null;
|
|
546
|
+
return {
|
|
547
|
+
levelKey,
|
|
548
|
+
floor: band.floor,
|
|
549
|
+
nextFloor,
|
|
550
|
+
deltaToFloor: band.floor === null ? null : value - band.floor,
|
|
551
|
+
deltaToNext: nextFloor === null ? null : nextFloor - value
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/recent/index.ts
|
|
556
|
+
const DEFAULT_WINDOW = 20;
|
|
557
|
+
function subjectParticipant(record, subject) {
|
|
558
|
+
if (subject.kind === "self") return record.participants.find((participant) => participant.relation === "self") ?? null;
|
|
559
|
+
return record.participants.find((participant) => participant.accountId === subject.accountId) ?? null;
|
|
560
|
+
}
|
|
561
|
+
function inDateRange(record, from, to) {
|
|
562
|
+
if (from === void 0 && to === void 0) return true;
|
|
563
|
+
if (record.battleTime === null) return false;
|
|
564
|
+
if (from !== void 0 && record.battleTime < from) return false;
|
|
565
|
+
if (to !== void 0 && record.battleTime > to) return false;
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
function compareNewestFirst(a, b) {
|
|
569
|
+
const timeA = a.battleTime ?? Number.NEGATIVE_INFINITY;
|
|
570
|
+
const timeB = b.battleTime ?? Number.NEGATIVE_INFINITY;
|
|
571
|
+
if (timeA !== timeB) return timeB - timeA;
|
|
572
|
+
return a.arenaId.localeCompare(b.arenaId);
|
|
573
|
+
}
|
|
574
|
+
function emptyStats() {
|
|
575
|
+
return {
|
|
576
|
+
battles: 0,
|
|
577
|
+
wins: 0,
|
|
578
|
+
losses: 0,
|
|
579
|
+
draws: 0,
|
|
580
|
+
winRate: null,
|
|
581
|
+
damage: 0,
|
|
582
|
+
frags: 0,
|
|
583
|
+
avgDamage: null,
|
|
584
|
+
avgFrags: null,
|
|
585
|
+
pr: null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Statistics over ingested matches only.
|
|
590
|
+
*
|
|
591
|
+
* No gap filling, no lifetime totals, no historical replay scan: a match is in
|
|
592
|
+
* the window only when it was successfully parsed and stored by the caller.
|
|
593
|
+
*/
|
|
594
|
+
function recentMatches(records, query, expected) {
|
|
595
|
+
const drawPolicy = query.drawPolicy ?? "loss";
|
|
596
|
+
const limit = query.last === void 0 ? DEFAULT_WINDOW : Math.max(0, Math.trunc(query.last));
|
|
597
|
+
let matches = records.filter((record) => {
|
|
598
|
+
if (query.matchGroups !== void 0 && !query.matchGroups.includes(record.matchGroup)) return false;
|
|
599
|
+
if (!inDateRange(record, query.from, query.to)) return false;
|
|
600
|
+
const participant = subjectParticipant(record, query.subject);
|
|
601
|
+
if (participant === null || participant.result === null) return false;
|
|
602
|
+
const outcome = outcomeOf(participant.teamId, record.winnerTeamId);
|
|
603
|
+
if (statOutcome(outcome) === null) return false;
|
|
604
|
+
if (drawPolicy === "exclude" && outcome === "draw") return false;
|
|
605
|
+
return true;
|
|
606
|
+
});
|
|
607
|
+
matches = matches.sort(compareNewestFirst).slice(0, limit);
|
|
608
|
+
const stats = emptyStats();
|
|
609
|
+
const ships = /* @__PURE__ */ new Map();
|
|
610
|
+
let damage = 0;
|
|
611
|
+
let frags = 0;
|
|
612
|
+
let wins = 0;
|
|
613
|
+
let losses = 0;
|
|
614
|
+
let draws = 0;
|
|
615
|
+
for (const record of matches) {
|
|
616
|
+
const participant = subjectParticipant(record, query.subject);
|
|
617
|
+
if (participant === null || participant.result === null) continue;
|
|
618
|
+
const outcome = outcomeOf(participant.teamId, record.winnerTeamId);
|
|
619
|
+
if (outcome === "win") wins += 1;
|
|
620
|
+
else if (outcome === "loss") losses += 1;
|
|
621
|
+
else if (outcome === "draw") draws += 1;
|
|
622
|
+
const resultDamage = participant.result.damage ?? 0;
|
|
623
|
+
const resultFrags = participant.result.frags ?? 0;
|
|
624
|
+
damage += resultDamage;
|
|
625
|
+
frags += resultFrags;
|
|
626
|
+
if (participant.shipId !== null) {
|
|
627
|
+
const key = String(participant.shipId);
|
|
628
|
+
const ship = ships.get(key);
|
|
629
|
+
if (ship === void 0) ships.set(key, {
|
|
630
|
+
shipId: participant.shipId,
|
|
631
|
+
battles: 1,
|
|
632
|
+
damage: resultDamage,
|
|
633
|
+
frags: resultFrags,
|
|
634
|
+
wins: outcome === "win" ? 1 : 0
|
|
635
|
+
});
|
|
636
|
+
else {
|
|
637
|
+
ship.battles += 1;
|
|
638
|
+
ship.damage += resultDamage;
|
|
639
|
+
ship.frags += resultFrags;
|
|
640
|
+
if (outcome === "win") ship.wins += 1;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const battles = matches.length;
|
|
645
|
+
stats.battles = battles;
|
|
646
|
+
stats.wins = wins;
|
|
647
|
+
stats.losses = losses;
|
|
648
|
+
stats.draws = draws;
|
|
649
|
+
stats.winRate = battles === 0 ? null : wins / battles * 100;
|
|
650
|
+
stats.damage = damage;
|
|
651
|
+
stats.frags = frags;
|
|
652
|
+
stats.avgDamage = battles === 0 ? null : damage / battles;
|
|
653
|
+
stats.avgFrags = battles === 0 ? null : frags / battles;
|
|
654
|
+
const sample = {
|
|
655
|
+
kind: "ingested",
|
|
656
|
+
matches: battles
|
|
657
|
+
};
|
|
658
|
+
if (query.from !== void 0) sample.from = new Date(query.from).toISOString();
|
|
659
|
+
if (query.to !== void 0) sample.to = new Date(query.to).toISOString();
|
|
660
|
+
if (query.subject.kind === "participant") sample.sharedWith = battles;
|
|
661
|
+
if (expected !== void 0 && ships.size > 0) stats.pr = accountPrPooled([...ships.values()], expected, sample);
|
|
662
|
+
return {
|
|
663
|
+
matches,
|
|
664
|
+
sample,
|
|
665
|
+
stats
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
export { PR_BANDS, PR_CATEGORY_LABELS, WIN_RATE_BANDS, accountPrEstimated, accountPrPooled, aggregatePlayers, aggregateShips, aggregateTeam, battlePr, dailySeries, gameDay, inRange, levelOf, parseBattleTime, prCategory, recentMatches, shipPr, weekBoundary, winRateCategory };
|