koishi-plugin-dwrgbwiki 2.4.0 → 2.4.2
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/index.js +70 -4
- package/match.js +7 -0
- package/matchdata.js +1083 -0
- package/package.json +5 -3
- package/readme.md +67 -0
- package/sqlite_reader.js +255 -0
- package/wikiwatch.js +593 -514
package/matchdata.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/* ============================================================
|
|
4
|
+
* 赛事 · 数据(按赛事统计的排行榜)
|
|
5
|
+
* ------------------------------------------------------------
|
|
6
|
+
* 口径与「赛事数据展示 2.0 / player.html」保持一致:
|
|
7
|
+
* 一局 = 一条半场记录,屠方得分 hs、求方得分 ss 互为对手
|
|
8
|
+
* 胜 / 平 / 负 : 本侧得分 >2 / ==2 / <2
|
|
9
|
+
* 四杀 / 被四杀 : hs == 5 (四杀有 1 分奖励,所以是 5 不是 4)
|
|
10
|
+
* 被四跑 / 四跑 : hs == 0
|
|
11
|
+
* 淘汰人数 : 胜*3 + 平*2 + 负*1 + 四杀*1 - 被四跑*1
|
|
12
|
+
* (四杀的 5 分里有 1 分是奖励,这里按「淘汰几个人」计)
|
|
13
|
+
* 局均得分 : Σ 本侧得分 / 局数
|
|
14
|
+
* 局均修机/牵制/救人 : Σ 求生者 N 的对应列 / 局数
|
|
15
|
+
* 出门率 : 出门次数 / 局数
|
|
16
|
+
* ============================================================ */
|
|
17
|
+
|
|
18
|
+
const { loadMatches, loadTeamMapping, loadIdMapping, getMainTeamName, loadAnnounceTime } = require('./match')
|
|
19
|
+
const sqlite = require('./sqlite_reader')
|
|
20
|
+
const zlib = require('zlib')
|
|
21
|
+
|
|
22
|
+
/* ---------------- 类型定义 ---------------- */
|
|
23
|
+
|
|
24
|
+
// 类型别名 -> 内部键
|
|
25
|
+
// 「求生者」指的是求生者【队伍】(四名求生者合计):
|
|
26
|
+
// 求生数据天然是队伍级的,所以不拆到选手;
|
|
27
|
+
// 监管者是一个人打,才分「队伍 / 选手」两种视角。
|
|
28
|
+
const TYPE_ALIAS = {
|
|
29
|
+
'求生者': 'survivorTeam',
|
|
30
|
+
'求生者队伍': 'survivorTeam',
|
|
31
|
+
'求生': 'survivorTeam',
|
|
32
|
+
'求队': 'survivorTeam',
|
|
33
|
+
'监管者队伍': 'hunterTeam',
|
|
34
|
+
'屠队': 'hunterTeam',
|
|
35
|
+
'监管者选手': 'hunterPlayer',
|
|
36
|
+
'监管者': 'hunterPlayer',
|
|
37
|
+
'监管': 'hunterPlayer',
|
|
38
|
+
'屠夫': 'hunterPlayer',
|
|
39
|
+
'队伍': 'teamStanding',
|
|
40
|
+
'队伍战绩': 'teamStanding',
|
|
41
|
+
'战绩': 'teamStanding',
|
|
42
|
+
'总榜': 'teamStanding',
|
|
43
|
+
// 求生者选手保留为隐藏别名,需要时仍可查
|
|
44
|
+
'求生者选手': 'survivorPlayer'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TYPE_META = {
|
|
48
|
+
survivorTeam: { title: '求生者队伍数据', kind: 'team', role: 'survivor' },
|
|
49
|
+
hunterTeam: { title: '监管者队伍数据', kind: 'team', role: 'hunter' },
|
|
50
|
+
hunterPlayer: { title: '监管者选手数据', kind: 'player', role: 'hunter' },
|
|
51
|
+
survivorPlayer: { title: '求生者选手数据', kind: 'player', role: 'survivor' },
|
|
52
|
+
teamStanding: { title: '队伍战绩', kind: 'standing', role: 'team' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TYPE_HELP = [
|
|
56
|
+
'赛事 数据 <类型> [赛事名] [-i 模式]',
|
|
57
|
+
'',
|
|
58
|
+
'类型:',
|
|
59
|
+
' 求生者 求生者队伍排行(局均修机/牵制/救人、出门率、被四杀/四跑)',
|
|
60
|
+
' 监管者队伍 按队伍统计的监管者数据(四杀/被四跑、局均淘汰/得分)',
|
|
61
|
+
' 监管者选手 按选手统计的监管者数据',
|
|
62
|
+
' 队伍 队伍战绩总榜(胜负场、胜负局、净胜分)',
|
|
63
|
+
'',
|
|
64
|
+
'赛事名可省略,省略时使用配置里的「默认赛事」;',
|
|
65
|
+
'也可以只写阶段(如 赛事 数据 求生者 COA预选赛),会自动取最近一届。',
|
|
66
|
+
'',
|
|
67
|
+
'示例:',
|
|
68
|
+
' 赛事 数据 求生者 2026COA预选赛',
|
|
69
|
+
' 赛事 数据 监管者选手 COA预选赛',
|
|
70
|
+
' 赛事 数据 队伍 2026IVL夏季赛常规赛 -i none'
|
|
71
|
+
].join('\n')
|
|
72
|
+
|
|
73
|
+
/* ---------------- 小工具 ---------------- */
|
|
74
|
+
|
|
75
|
+
function norm(v) {
|
|
76
|
+
if (v === null || v === undefined) return ''
|
|
77
|
+
return String(v).trim()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isNil(v) {
|
|
81
|
+
return v === -999 || v === null || v === undefined || v === '' || v === '-' || v === '未填'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function num(v) {
|
|
85
|
+
if (isNil(v)) return null
|
|
86
|
+
const n = parseFloat(String(v))
|
|
87
|
+
return isFinite(n) ? n : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function n0(v) {
|
|
91
|
+
return (v === null || v === undefined || !isFinite(v)) ? 0 : v
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function div(a, b) {
|
|
95
|
+
return b ? a / b : 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function esc(s) {
|
|
99
|
+
return String(s == null ? '' : s)
|
|
100
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fixed(v, d) {
|
|
104
|
+
if (v === null || v === undefined || !isFinite(v)) return '-'
|
|
105
|
+
return Number(v).toFixed(d)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pct(v) {
|
|
109
|
+
if (v === null || v === undefined || !isFinite(v)) return '-'
|
|
110
|
+
return Number(v).toFixed(1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ---------------- 数据源 ---------------- */
|
|
114
|
+
/**
|
|
115
|
+
* 2.0 的数据都在 data/sqlite/all.db 里。这里把它读成和旧 JSON 完全一样的结构
|
|
116
|
+
* (赛段 / 阶段 / 主场 / 客场 / MVP / games[...]),下游 computeTables 一行不用改。
|
|
117
|
+
* 单位差异:all.db 的 p{i}_dec 是百分数(244 = 2.44 台),这里统一换算成「台」。
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
function dataBase(config) {
|
|
121
|
+
return String(config.dataSource || 'https://website.homeworkkun.top').replace(/\/+$/, '')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function fetchBuffer(url, timeout) {
|
|
125
|
+
const ctrl = new AbortController()
|
|
126
|
+
const timer = setTimeout(function () { ctrl.abort() }, timeout)
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(url, { signal: ctrl.signal })
|
|
129
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url)
|
|
130
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function fetchJson(url, timeout) {
|
|
137
|
+
const ctrl = new AbortController()
|
|
138
|
+
const timer = setTimeout(function () { ctrl.abort() }, timeout)
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(url, { signal: ctrl.signal, headers: { 'Cache-Control': 'no-cache' } })
|
|
141
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url)
|
|
142
|
+
return JSON.parse(await res.text())
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ---------------- 云端 overlay(和 2.0 网站同一套机制) ----------------
|
|
149
|
+
* 站点在发布时会把「正在更新的赛事」推到对象存储:
|
|
150
|
+
* index.json -> 在架赛事清单(含 digest / path / gz_path)
|
|
151
|
+
* tournaments/<digest>.db(.gz) -> 单赛事的 overlay 小库(结构和 all.db 一致)
|
|
152
|
+
* all.db 要等站点重新构建才会包含这些赛事,所以这里按 2.0 前端的做法把它们叠上去:
|
|
153
|
+
* digest 和本地 manifest 一致的跳过,其余下载 overlay、替换掉同名赛事的全部数据。
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
const OVERLAY_INDEX_TTL = 5 * 60 * 1000 // 云端清单缓存 5 分钟
|
|
157
|
+
let remoteCfgCache = null
|
|
158
|
+
let overlayIndexCache = null
|
|
159
|
+
let overlayIndexAt = 0
|
|
160
|
+
const overlayCache = new Map() // slug -> { digest, matches }(按 digest 内容寻址,可长期复用)
|
|
161
|
+
|
|
162
|
+
async function loadRemoteCfg(config) {
|
|
163
|
+
if (remoteCfgCache) return remoteCfgCache
|
|
164
|
+
const base = dataBase(config)
|
|
165
|
+
try {
|
|
166
|
+
const cfg = await fetchJson(base + '/赛事数据展示/data/sqlite/remote.json', config.timeout || 15000)
|
|
167
|
+
remoteCfgCache = (cfg && cfg.enabled !== false) ? cfg : null
|
|
168
|
+
} catch (e) {
|
|
169
|
+
remoteCfgCache = null
|
|
170
|
+
}
|
|
171
|
+
return remoteCfgCache
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function loadLocalDigests(config) {
|
|
175
|
+
const base = dataBase(config)
|
|
176
|
+
try {
|
|
177
|
+
const mf = await fetchJson(base + '/赛事数据展示/data/sqlite/manifest.json', config.timeout || 15000)
|
|
178
|
+
const map = {}
|
|
179
|
+
;((mf && mf.tournaments) || []).forEach(function (t) {
|
|
180
|
+
if (t && t.slug) map[t.slug] = t.digest
|
|
181
|
+
})
|
|
182
|
+
return map
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function tourKeyOf(match) {
|
|
189
|
+
return String(match['赛段']) + String(match['阶段'])
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 拉 index.json(带 5 分钟缓存);失败返回 null,不影响本地数据 */
|
|
193
|
+
async function loadOverlayIndex(config, force) {
|
|
194
|
+
const remote = await loadRemoteCfg(config)
|
|
195
|
+
if (!remote || !remote.base) return null
|
|
196
|
+
if (!force && overlayIndexCache && Date.now() - overlayIndexAt < OVERLAY_INDEX_TTL) return overlayIndexCache
|
|
197
|
+
|
|
198
|
+
const url = String(remote.base).replace(/\/+$/, '') + '/' + (remote.index || 'index.json')
|
|
199
|
+
try {
|
|
200
|
+
const idx = await fetchJson(url + '?t=' + Date.now(), Math.max(Number(remote.timeoutMs) || 0, 8000))
|
|
201
|
+
overlayIndexAt = Date.now()
|
|
202
|
+
overlayIndexCache = idx
|
|
203
|
+
return idx
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.warn('[dwrgbwiki] 读取云端 index.json 失败:' + (e && e.message ? e.message : e))
|
|
206
|
+
overlayIndexCache = null
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 把一个 overlay 小库解析成 match 对象数组 */
|
|
212
|
+
async function loadOverlayMatches(config, remote, entry) {
|
|
213
|
+
const rel = entry.gz_path || entry.path
|
|
214
|
+
if (!rel) throw new Error('索引缺少 path')
|
|
215
|
+
const url = String(remote.base).replace(/\/+$/, '') + '/' + String(rel).replace(/^\/+/, '')
|
|
216
|
+
const raw = await fetchBuffer(url, Math.max(config.timeout || 20000, 60000))
|
|
217
|
+
const bytes = /\.gz(\?|$)/.test(rel)
|
|
218
|
+
? new Uint8Array(zlib.gunzipSync(Buffer.from(raw)))
|
|
219
|
+
: raw
|
|
220
|
+
const odb = sqlite.openSqlite(bytes)
|
|
221
|
+
return buildMatchesFromDb(odb).filter(function (m) { return tourKeyOf(m) === String(entry.slug) })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 把云端在架的赛事叠到本地 all.db 的数据上 */
|
|
225
|
+
async function applyOverlays(config, baseMatches) {
|
|
226
|
+
if (config.matchCloud === false || String(config.matchCloud || '').toLowerCase() === 'off') return baseMatches
|
|
227
|
+
const remote = await loadRemoteCfg(config)
|
|
228
|
+
if (!remote) return baseMatches
|
|
229
|
+
const idx = await loadOverlayIndex(config)
|
|
230
|
+
const list = (idx && idx.tournaments) || []
|
|
231
|
+
if (!list.length) return baseMatches
|
|
232
|
+
|
|
233
|
+
const digests = await loadLocalDigests(config)
|
|
234
|
+
const skipIdentical = remote.skipIdentical !== false
|
|
235
|
+
const applied = []
|
|
236
|
+
const failed = []
|
|
237
|
+
|
|
238
|
+
for (const entry of list) {
|
|
239
|
+
if (!entry || !entry.slug) continue
|
|
240
|
+
if (skipIdentical && entry.digest && digests[entry.slug] === entry.digest) continue
|
|
241
|
+
|
|
242
|
+
const slug = String(entry.slug)
|
|
243
|
+
let hit = overlayCache.get(slug)
|
|
244
|
+
if (!hit || (entry.digest && hit.digest !== entry.digest)) {
|
|
245
|
+
try {
|
|
246
|
+
const rows = await loadOverlayMatches(config, remote, entry)
|
|
247
|
+
if (!rows.length) { failed.push(slug); continue }
|
|
248
|
+
hit = { digest: entry.digest || '', matches: rows }
|
|
249
|
+
overlayCache.set(slug, hit)
|
|
250
|
+
} catch (e) {
|
|
251
|
+
failed.push(slug)
|
|
252
|
+
console.warn('[dwrgbwiki] 云端赛事 ' + slug + ' 加载失败:' + (e && e.message ? e.message : e))
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
applied.push({ slug: slug, matches: hit.matches })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!applied.length && !failed.length) return baseMatches
|
|
260
|
+
|
|
261
|
+
const replaced = new Set(applied.map(function (x) { return x.slug }))
|
|
262
|
+
const out = baseMatches.filter(function (m) { return !replaced.has(tourKeyOf(m)) })
|
|
263
|
+
applied.forEach(function (x) { for (const m of x.matches) out.push(m) })
|
|
264
|
+
if (applied.length) console.log('[dwrgbwiki] 已叠加 ' + applied.length + ' 个云端赛事:' + applied.map(function (x) { return x.slug }).join('、'))
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let dbCache = null
|
|
269
|
+
let dbCacheAt = 0
|
|
270
|
+
let dbCacheKey = ''
|
|
271
|
+
|
|
272
|
+
const DB_CACHE_TTL = 6 * 60 * 60 * 1000
|
|
273
|
+
|
|
274
|
+
/** 把一个 SQLite 库(all.db 或云端 overlay 小库)转成 match 对象数组 */
|
|
275
|
+
function buildMatchesFromDb(db) {
|
|
276
|
+
// 注意:matches_meta 的主键是 (tournament, match_id),
|
|
277
|
+
// 同一个 match_id 可能在不同赛事库里重复出现,只用 match_id 做 key 会把两场并成一场。
|
|
278
|
+
function mkey(tournament, matchId) {
|
|
279
|
+
return String(tournament) + '\u0001' + String(matchId)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const meta = new Map()
|
|
283
|
+
db.eachObject('matches_meta', function (m) {
|
|
284
|
+
if (m && m.match_id) meta.set(mkey(m.tournament, m.match_id), m)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const byMatch = new Map()
|
|
288
|
+
let skipped = 0
|
|
289
|
+
|
|
290
|
+
function ensureMatch(r) {
|
|
291
|
+
const mid = mkey(r.tournament, r.match_id)
|
|
292
|
+
let m = byMatch.get(mid)
|
|
293
|
+
if (m) return m
|
|
294
|
+
const mm = meta.get(mid) || {}
|
|
295
|
+
const key = String(r.tournament || '')
|
|
296
|
+
const mt = /^(\d{4})(.*)$/.exec(key)
|
|
297
|
+
const home = String(mm.home_team || '')
|
|
298
|
+
const away = String(mm.away_team || '')
|
|
299
|
+
m = {
|
|
300
|
+
赛段: mt ? Number(mt[1]) : (Number(r.season) || ''),
|
|
301
|
+
阶段: mt ? mt[2] : key,
|
|
302
|
+
主场: home,
|
|
303
|
+
客场: away,
|
|
304
|
+
MVP: String(mm.mvp_name || ''),
|
|
305
|
+
games: []
|
|
306
|
+
}
|
|
307
|
+
byMatch.set(mid, m)
|
|
308
|
+
return m
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
db.eachObject('records', function (r) {
|
|
312
|
+
const mm = meta.get(mkey(r.tournament, r.match_id)) || {}
|
|
313
|
+
const home = String(mm.home_team || '')
|
|
314
|
+
const away = String(mm.away_team || '')
|
|
315
|
+
const hunter = String(r.hunter_team || '')
|
|
316
|
+
if (!home || !away || !hunter) { skipped += 1; return }
|
|
317
|
+
|
|
318
|
+
let side = ''
|
|
319
|
+
if (hunter === home) side = '主'
|
|
320
|
+
else if (hunter === away) side = '客'
|
|
321
|
+
else {
|
|
322
|
+
// 大小写/空格不一致时兜底;再不行就跳过这一条
|
|
323
|
+
const h = hunter.toLowerCase()
|
|
324
|
+
if (h === home.toLowerCase()) side = '主'
|
|
325
|
+
else if (h === away.toLowerCase()) side = '客'
|
|
326
|
+
else { skipped += 1; return }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const m = ensureMatch(r)
|
|
330
|
+
let sh = num(r.score_home)
|
|
331
|
+
let sa = num(r.score_away)
|
|
332
|
+
|
|
333
|
+
// 云端 overlay 里 score_home / score_away / win_side 是空的
|
|
334
|
+
// (录制系统只存每半场的选手数据,比分是站点构建时才算出来的)。
|
|
335
|
+
// 这里用求生者结果反推:淘汰数 = 4 − 逃脱数,四杀(4 淘汰)记 5 分。
|
|
336
|
+
// 已用 all.db 里 553 个半场验证:反推值与官方比分 100% 一致。
|
|
337
|
+
if (sh === null || sa === null) {
|
|
338
|
+
const res = []
|
|
339
|
+
for (let i = 1; i <= 4; i++) res.push(String(r['p' + i + '_res'] || ''))
|
|
340
|
+
if (res.every(function (x) { return x === '逃脱' || x === '淘汰' })) {
|
|
341
|
+
const escapes = res.filter(function (x) { return x === '逃脱' }).length
|
|
342
|
+
const kills = 4 - escapes
|
|
343
|
+
const hs = kills === 4 ? 5 : kills
|
|
344
|
+
if (side === '主') { sh = hs; sa = escapes } else { sa = hs; sh = escapes }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const g = {
|
|
349
|
+
场次号: String(r.match_num || ''),
|
|
350
|
+
屠队主客: side,
|
|
351
|
+
屠队: hunter,
|
|
352
|
+
屠名: String(r.h_id || ''),
|
|
353
|
+
主分: sh === null ? 0 : sh,
|
|
354
|
+
客分: sa === null ? 0 : sa,
|
|
355
|
+
胜利方: String(r.win_side || ''),
|
|
356
|
+
屠选: String(r.h_role || '')
|
|
357
|
+
}
|
|
358
|
+
for (let i = 1; i <= 4; i++) {
|
|
359
|
+
const p = 'p' + i + '_'
|
|
360
|
+
g['求生者' + i + 'ID'] = String(r[p + 'id'] || '')
|
|
361
|
+
g['使用角色' + i] = String(r[p + 'role'] || '')
|
|
362
|
+
const dec = num(r[p + 'dec'])
|
|
363
|
+
g['修机进度' + i] = dec === null ? 0 : dec / 100 // 244 -> 2.44 台
|
|
364
|
+
const kite = num(r[p + 'kite'])
|
|
365
|
+
g['牵制时长' + i] = kite === null ? 0 : kite
|
|
366
|
+
const save = num(r[p + 'save'])
|
|
367
|
+
g['救人次数' + i] = save === null ? 0 : save
|
|
368
|
+
const board = num(r[p + 'board'])
|
|
369
|
+
g['砸板命中' + i] = board === null ? 0 : board
|
|
370
|
+
const res = String(r[p + 'res'] || '')
|
|
371
|
+
g['出门' + i] = res === '逃脱' ? '出门' : (res === '淘汰' ? '淘汰' : '-')
|
|
372
|
+
}
|
|
373
|
+
m.games.push(g)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const out = Array.from(byMatch.values()).filter(function (m) { return m.games.length })
|
|
377
|
+
if (skipped) console.warn('[dwrgbwiki] 赛事数据:' + skipped + ' 条半场记录因缺少队伍信息被跳过')
|
|
378
|
+
return out
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function loadFromDb(config) {
|
|
382
|
+
const base = dataBase(config)
|
|
383
|
+
let baseMatches = null
|
|
384
|
+
if (dbCache && dbCacheKey === base && Date.now() - dbCacheAt < DB_CACHE_TTL) {
|
|
385
|
+
baseMatches = dbCache
|
|
386
|
+
} else {
|
|
387
|
+
const timeout = Math.max(config.timeout || 20000, 60000)
|
|
388
|
+
const buf = await fetchBuffer(base + '/赛事数据展示/data/sqlite/all.db', timeout)
|
|
389
|
+
baseMatches = buildMatchesFromDb(sqlite.openSqlite(buf))
|
|
390
|
+
dbCache = baseMatches
|
|
391
|
+
dbCacheAt = Date.now()
|
|
392
|
+
dbCacheKey = base
|
|
393
|
+
}
|
|
394
|
+
// 云端 overlay 每次都会过一遍(index 有 5 分钟缓存、overlay 按 digest 长期缓存),
|
|
395
|
+
// 这样「正在实时更新的赛事」不用等站点重新构建 all.db
|
|
396
|
+
return applyOverlays(config, baseMatches)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 按配置选数据源;sqlite 失败自动回落到旧 JSON */
|
|
400
|
+
async function loadDataset(config) {
|
|
401
|
+
const mode = String(config.matchDataSource || 'sqlite').toLowerCase()
|
|
402
|
+
if (mode === 'json') return loadMatches(config)
|
|
403
|
+
try {
|
|
404
|
+
return await loadFromDb(config)
|
|
405
|
+
} catch (e) {
|
|
406
|
+
console.warn('[dwrgbwiki] 读取 all.db 失败,回落到旧 JSON:' + (e && e.message ? e.message : e))
|
|
407
|
+
dbCache = null
|
|
408
|
+
dbCacheAt = 0
|
|
409
|
+
return loadMatches(config)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ---------------- 赛事清单 ---------------- */
|
|
414
|
+
|
|
415
|
+
function listTours(matches) {
|
|
416
|
+
const map = new Map()
|
|
417
|
+
for (const m of matches) {
|
|
418
|
+
const season = norm(m['赛段'])
|
|
419
|
+
const phase = norm(m['阶段'])
|
|
420
|
+
if (!season || !phase) continue
|
|
421
|
+
const key = season + phase
|
|
422
|
+
let t = map.get(key)
|
|
423
|
+
if (!t) {
|
|
424
|
+
t = { key: key, season: season, phase: phase, matches: 0, games: 0 }
|
|
425
|
+
map.set(key, t)
|
|
426
|
+
}
|
|
427
|
+
t.matches += 1
|
|
428
|
+
t.games += (Array.isArray(m.games) ? m.games.length : 0)
|
|
429
|
+
}
|
|
430
|
+
return Array.from(map.values()).sort(function (a, b) {
|
|
431
|
+
return String(b.season).localeCompare(String(a.season), 'zh') || String(a.phase).localeCompare(String(b.phase), 'zh')
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 把用户输入的赛事名解析成赛事对象。
|
|
437
|
+
* 支持:完整名(2026COA预选赛)、只写阶段(COA预选赛,必须唯一)、包含匹配(唯一时)。
|
|
438
|
+
*/
|
|
439
|
+
function resolveTour(tours, name) {
|
|
440
|
+
const q = norm(name).toLowerCase()
|
|
441
|
+
if (!q) return null
|
|
442
|
+
|
|
443
|
+
let hit = tours.find(function (t) { return t.key.toLowerCase() === q })
|
|
444
|
+
if (hit) return hit
|
|
445
|
+
|
|
446
|
+
const byPhase = tours.filter(function (t) { return t.phase.toLowerCase() === q })
|
|
447
|
+
if (byPhase.length === 1) return byPhase[0]
|
|
448
|
+
if (byPhase.length > 1) {
|
|
449
|
+
// 只写了阶段(很容易撞车,比如每一年都有「COA预选赛」):
|
|
450
|
+
// 取最近的一届,标题里会写明完整赛事名,不会产生歧义。
|
|
451
|
+
const latest = byPhase.slice().sort(function (a, b) {
|
|
452
|
+
return String(b.season).localeCompare(String(a.season), 'zh')
|
|
453
|
+
})[0]
|
|
454
|
+
latest.assumed = true
|
|
455
|
+
return latest
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const fuzzy = tours.filter(function (t) { return t.key.toLowerCase().indexOf(q) >= 0 })
|
|
459
|
+
if (fuzzy.length === 1) return fuzzy[0]
|
|
460
|
+
if (fuzzy.length > 1) {
|
|
461
|
+
const err = new Error('赛事名「' + name + '」匹配到多个赛事,请写得更具体:' + fuzzy.slice(0, 8).map(function (t) { return t.key }).join(' / '))
|
|
462
|
+
err.code = 'TOUR_AMBIGUOUS'
|
|
463
|
+
throw err
|
|
464
|
+
}
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function recentTours(tours, n) {
|
|
469
|
+
return tours.slice(0, n || 6).map(function (t) { return t.key })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ---------------- 统计 ---------------- */
|
|
473
|
+
|
|
474
|
+
function blankEntry(name, extra) {
|
|
475
|
+
return Object.assign({
|
|
476
|
+
name: name,
|
|
477
|
+
teams: new Map(), // 队伍 -> 出现次数(取最多的那个作为「所属队伍」)
|
|
478
|
+
games: 0, wins: 0, draws: 0, losses: 0,
|
|
479
|
+
quadKills: 0, quadEscapes: 0,
|
|
480
|
+
score: 0, // 本侧得分合计
|
|
481
|
+
kills: 0, // 淘汰人数(按 2.0 口径换算)
|
|
482
|
+
machines: 0, kite: 0, rescue: 0, board: 0, escaped: 0,
|
|
483
|
+
mvp: 0,
|
|
484
|
+
chars: new Map()
|
|
485
|
+
}, extra || {})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function bump(map, key, inc) {
|
|
489
|
+
if (!key) return
|
|
490
|
+
map.set(key, (map.get(key) || 0) + (inc === undefined ? 1 : inc))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function topOf(map, n) {
|
|
494
|
+
return Array.from(map.entries())
|
|
495
|
+
.sort(function (a, b) { return b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), 'zh') })
|
|
496
|
+
.slice(0, n)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function primaryOf(map) {
|
|
500
|
+
const top = topOf(map, 1)
|
|
501
|
+
return top.length ? top[0][0] : ''
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function mergeName(name, mapping, enable) {
|
|
505
|
+
if (!enable) return name
|
|
506
|
+
return getMainTeamName(name, mapping) || name
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 统计一个赛事的四种排行。
|
|
511
|
+
* @param {Array} matches 全部比赛(loadMatches 的结果)
|
|
512
|
+
* @param {Object} tour listTours 里的一项
|
|
513
|
+
* @param {Object} opt { mergeMode, teamMapping, idMapping, mvpHolder }
|
|
514
|
+
*/
|
|
515
|
+
function computeTables(matches, tour, opt) {
|
|
516
|
+
opt = opt || {}
|
|
517
|
+
const mode = opt.mergeMode || 'team'
|
|
518
|
+
const minGames = Math.max(1, Number(opt.minGames) || 1)
|
|
519
|
+
const mergeTeam = mode === 'team' || mode === 'all'
|
|
520
|
+
const mergePlayer = mode === 'player' || mode === 'all'
|
|
521
|
+
const teamMap = opt.teamMapping || {}
|
|
522
|
+
const idMap = opt.idMapping || {}
|
|
523
|
+
|
|
524
|
+
const hunterPlayer = new Map()
|
|
525
|
+
const survivorPlayer = new Map()
|
|
526
|
+
const hunterTeam = new Map()
|
|
527
|
+
const survivorTeam = new Map()
|
|
528
|
+
const standings = new Map()
|
|
529
|
+
|
|
530
|
+
function standingEntry(name) {
|
|
531
|
+
let e = standings.get(name)
|
|
532
|
+
if (!e) {
|
|
533
|
+
e = {
|
|
534
|
+
name: name, matches: 0, wins: 0, losses: 0, ties: 0,
|
|
535
|
+
roundsWin: 0, roundsLoss: 0, score: 0, conceded: 0
|
|
536
|
+
}
|
|
537
|
+
standings.set(name, e)
|
|
538
|
+
}
|
|
539
|
+
return e
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function playerId(id) {
|
|
543
|
+
if (!mergePlayer) return id
|
|
544
|
+
// match.js 里的 getMainId 依赖 index 结构,这里用同构的简化版本
|
|
545
|
+
const lower = String(id).toLowerCase()
|
|
546
|
+
for (const main of Object.keys(idMap || {})) {
|
|
547
|
+
if (String(main).toLowerCase() === lower) return main
|
|
548
|
+
const aliases = idMap[main] || []
|
|
549
|
+
if (Array.isArray(aliases) && aliases.some(function (a) { return String(a).toLowerCase() === lower })) return main
|
|
550
|
+
}
|
|
551
|
+
return id
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let gameCount = 0
|
|
555
|
+
|
|
556
|
+
for (const m of matches) {
|
|
557
|
+
const season = norm(m['赛段'])
|
|
558
|
+
const phase = norm(m['阶段'])
|
|
559
|
+
if (season + phase !== tour.key) continue
|
|
560
|
+
|
|
561
|
+
const home = norm(m['主场'])
|
|
562
|
+
const away = norm(m['客场'])
|
|
563
|
+
const mvpRaw = norm(m['MVP'])
|
|
564
|
+
const mvp = mvpRaw && mvpRaw !== '-999' ? playerId(mvpRaw) : ''
|
|
565
|
+
// MVP 是「每场比赛」评一次,不是每个半场一次;
|
|
566
|
+
// 同一个选手一场里打了好几个半场也只记 1 次。
|
|
567
|
+
let mvpHunterDone = false // 监管者选手
|
|
568
|
+
let mvpHunterTeamDone = false // 监管者队伍
|
|
569
|
+
let mvpSurvivorDone = false
|
|
570
|
+
|
|
571
|
+
const games = Array.isArray(m.games) ? m.games : []
|
|
572
|
+
|
|
573
|
+
/* ---- 队伍战绩(按比赛 / 按 BO 局结算,主分客分就是两队小分) ---- */
|
|
574
|
+
if (home && away && games.length) {
|
|
575
|
+
const ta = standingEntry(mergeName(home, teamMap, mergeTeam))
|
|
576
|
+
const tb = standingEntry(mergeName(away, teamMap, mergeTeam))
|
|
577
|
+
const bos = new Map()
|
|
578
|
+
for (const g of games) {
|
|
579
|
+
const hs0 = num(g['主分'])
|
|
580
|
+
const as0 = num(g['客分'])
|
|
581
|
+
if (hs0 === null || as0 === null) continue
|
|
582
|
+
const boKey = norm(g['场次号']) || '?'
|
|
583
|
+
const r = bos.get(boKey) || { home: 0, away: 0 }
|
|
584
|
+
r.home += hs0
|
|
585
|
+
r.away += as0
|
|
586
|
+
bos.set(boKey, r)
|
|
587
|
+
ta.score += hs0; ta.conceded += as0
|
|
588
|
+
tb.score += as0; tb.conceded += hs0
|
|
589
|
+
}
|
|
590
|
+
if (bos.size) {
|
|
591
|
+
let homeBow = 0, awayBow = 0
|
|
592
|
+
bos.forEach(function (r) {
|
|
593
|
+
if (r.home > r.away) homeBow += 1
|
|
594
|
+
else if (r.away > r.home) awayBow += 1
|
|
595
|
+
})
|
|
596
|
+
ta.matches += 1; tb.matches += 1
|
|
597
|
+
ta.roundsWin += homeBow; ta.roundsLoss += awayBow
|
|
598
|
+
tb.roundsWin += awayBow; tb.roundsLoss += homeBow
|
|
599
|
+
if (homeBow > awayBow) { ta.wins += 1; tb.losses += 1 }
|
|
600
|
+
else if (awayBow > homeBow) { tb.wins += 1; ta.losses += 1 }
|
|
601
|
+
else { ta.ties += 1; tb.ties += 1 }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
for (const g of games) {
|
|
606
|
+
const side = norm(g['屠队主客'])
|
|
607
|
+
if (side !== '主' && side !== '客') continue
|
|
608
|
+
|
|
609
|
+
const homeScore = num(g['主分'])
|
|
610
|
+
const awayScore = num(g['客分'])
|
|
611
|
+
if (homeScore === null || awayScore === null) continue
|
|
612
|
+
|
|
613
|
+
// 屠方 / 求方得分(主客分是「本方得分」,谁当屠夫就取谁那边)
|
|
614
|
+
const hs = side === '主' ? homeScore : awayScore
|
|
615
|
+
const ss = side === '主' ? awayScore : homeScore
|
|
616
|
+
|
|
617
|
+
const hunterRaw = norm(g['屠队']) || (side === '主' ? home : away)
|
|
618
|
+
const hunterTeamName = mergeName(hunterRaw, teamMap, mergeTeam)
|
|
619
|
+
const survivTeamName = mergeName(side === '主' ? away : home, teamMap, mergeTeam)
|
|
620
|
+
|
|
621
|
+
const hunterId = playerId(norm(g['屠名']))
|
|
622
|
+
const hunterChar = norm(g['屠选'])
|
|
623
|
+
if (!hunterId || hunterId === '-999') continue
|
|
624
|
+
|
|
625
|
+
gameCount += 1
|
|
626
|
+
|
|
627
|
+
/* ---- 监管者选手 ---- */
|
|
628
|
+
let hp = hunterPlayer.get(hunterId)
|
|
629
|
+
if (!hp) { hp = blankEntry(hunterId); hunterPlayer.set(hunterId, hp) }
|
|
630
|
+
hp.games += 1
|
|
631
|
+
if (hs > 2) hp.wins += 1
|
|
632
|
+
else if (hs === 2) hp.draws += 1
|
|
633
|
+
else hp.losses += 1
|
|
634
|
+
if (hs === 5) hp.quadKills += 1
|
|
635
|
+
if (ss === 5) hp.quadEscapes += 1
|
|
636
|
+
hp.score += hs
|
|
637
|
+
bump(hp.teams, hunterTeamName)
|
|
638
|
+
bump(hp.chars, hunterChar)
|
|
639
|
+
if (mvp && mvp === hunterId && !mvpHunterDone) { hp.mvp += 1; mvpHunterDone = true }
|
|
640
|
+
|
|
641
|
+
/* ---- 监管者队伍 ---- */
|
|
642
|
+
let ht = hunterTeam.get(hunterTeamName)
|
|
643
|
+
if (!ht) { ht = blankEntry(hunterTeamName); hunterTeam.set(hunterTeamName, ht) }
|
|
644
|
+
ht.games += 1
|
|
645
|
+
if (hs > 2) ht.wins += 1
|
|
646
|
+
else if (hs === 2) ht.draws += 1
|
|
647
|
+
else ht.losses += 1
|
|
648
|
+
if (hs === 5) ht.quadKills += 1
|
|
649
|
+
if (ss === 5) ht.quadEscapes += 1
|
|
650
|
+
ht.score += hs
|
|
651
|
+
bump(ht.chars, hunterChar)
|
|
652
|
+
if (mvp && mvp === hunterId && !mvpHunterTeamDone) { ht.mvp += 1; mvpHunterTeamDone = true }
|
|
653
|
+
|
|
654
|
+
/* ---- 求生者 ---- */
|
|
655
|
+
let teamMachines = 0, teamKite = 0, teamRescue = 0, teamEscaped = 0
|
|
656
|
+
let teamHasMvp = false
|
|
657
|
+
const stChars = new Map()
|
|
658
|
+
for (let i = 1; i <= 4; i++) {
|
|
659
|
+
const rawId = norm(g['求生者' + i + 'ID'])
|
|
660
|
+
if (!rawId || rawId === '-999') continue
|
|
661
|
+
const sid = playerId(rawId)
|
|
662
|
+
const machinesRaw = num(g['修机进度' + i])
|
|
663
|
+
const kiteRaw = num(g['牵制时长' + i])
|
|
664
|
+
const rescueRaw = num(g['救人次数' + i])
|
|
665
|
+
const boardRaw = num(g['砸板命中' + i])
|
|
666
|
+
const escaped = norm(g['出门' + i]) === '出门'
|
|
667
|
+
const roleChar = norm(g['使用角色' + i])
|
|
668
|
+
|
|
669
|
+
let sp = survivorPlayer.get(sid)
|
|
670
|
+
if (!sp) { sp = blankEntry(sid); survivorPlayer.set(sid, sp) }
|
|
671
|
+
sp.games += 1
|
|
672
|
+
if (ss > 2) sp.wins += 1
|
|
673
|
+
else if (ss === 2) sp.draws += 1
|
|
674
|
+
else sp.losses += 1
|
|
675
|
+
if (hs === 5) sp.quadKills += 1
|
|
676
|
+
if (hs === 0) sp.quadEscapes += 1
|
|
677
|
+
sp.machines += n0(machinesRaw)
|
|
678
|
+
sp.kite += n0(kiteRaw)
|
|
679
|
+
sp.rescue += n0(rescueRaw)
|
|
680
|
+
sp.board += n0(boardRaw)
|
|
681
|
+
if (escaped) sp.escaped += 1
|
|
682
|
+
bump(sp.teams, survivTeamName)
|
|
683
|
+
bump(sp.chars, roleChar)
|
|
684
|
+
if (mvp && mvp === sid && !mvpSurvivorDone) { sp.mvp += 1; mvpSurvivorDone = true; teamHasMvp = true }
|
|
685
|
+
|
|
686
|
+
teamMachines += n0(machinesRaw)
|
|
687
|
+
teamKite += n0(kiteRaw)
|
|
688
|
+
teamRescue += n0(rescueRaw)
|
|
689
|
+
if (escaped) teamEscaped += 1
|
|
690
|
+
if (roleChar) bump(stChars, roleChar)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* ---- 求生者队伍 ---- */
|
|
694
|
+
let st = survivorTeam.get(survivTeamName)
|
|
695
|
+
if (!st) { st = blankEntry(survivTeamName); survivorTeam.set(survivTeamName, st) }
|
|
696
|
+
st.games += 1
|
|
697
|
+
if (ss > 2) st.wins += 1
|
|
698
|
+
else if (ss === 2) st.draws += 1
|
|
699
|
+
else st.losses += 1
|
|
700
|
+
if (hs === 5) st.quadKills += 1
|
|
701
|
+
if (hs === 0) st.quadEscapes += 1
|
|
702
|
+
st.machines += teamMachines
|
|
703
|
+
st.kite += teamKite
|
|
704
|
+
st.rescue += teamRescue
|
|
705
|
+
st.escaped += teamEscaped
|
|
706
|
+
stChars.forEach(function (v, k) { bump(st.chars, k, v) })
|
|
707
|
+
if (teamHasMvp) st.mvp += 1
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// 淘汰人数(2.0 口径)
|
|
712
|
+
for (const e of hunterPlayer.values()) {
|
|
713
|
+
e.kills = e.wins * 3 + e.draws * 2 + e.losses * 1 + e.quadKills - e.quadEscapes
|
|
714
|
+
}
|
|
715
|
+
for (const e of hunterTeam.values()) {
|
|
716
|
+
e.kills = e.wins * 3 + e.draws * 2 + e.losses * 1 + e.quadKills - e.quadEscapes
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
tournament: tour,
|
|
721
|
+
gameCount: gameCount,
|
|
722
|
+
minGames: minGames,
|
|
723
|
+
hunterPlayer: finalize(hunterPlayer, 'hunter', 'player', minGames),
|
|
724
|
+
hunterTeam: finalize(hunterTeam, 'hunter', 'team', minGames),
|
|
725
|
+
survivorPlayer: finalize(survivorPlayer, 'survivor', 'player', minGames),
|
|
726
|
+
survivorTeam: finalize(survivorTeam, 'survivor', 'team', minGames),
|
|
727
|
+
teamStanding: finalizeStanding(standings, minGames)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function finalize(map, role, kind, minGames) {
|
|
732
|
+
// 局数门槛:默认 1(与 2.0 页面的「最少局数」默认值一致),
|
|
733
|
+
// 调高可以过滤掉只打了 1、2 局的选手,避免榜首全是小样本。
|
|
734
|
+
const gate = Math.max(1, Number(minGames) || 1)
|
|
735
|
+
const rows = Array.from(map.values()).filter(function (e) { return e.games >= gate })
|
|
736
|
+
for (const e of rows) {
|
|
737
|
+
e.team = primaryOf(e.teams)
|
|
738
|
+
e.charsTop = topOf(e.chars, kind === 'team' ? 5 : (role === 'hunter' ? 3 : 5))
|
|
739
|
+
e.avgScore = div(e.score, e.games)
|
|
740
|
+
e.avgKills = div(e.kills, e.games)
|
|
741
|
+
e.avgMachines = div(e.machines, e.games)
|
|
742
|
+
e.avgKite = div(e.kite, e.games)
|
|
743
|
+
e.avgRescue = div(e.rescue, e.games)
|
|
744
|
+
e.avgBoard = div(e.board, e.games)
|
|
745
|
+
// 选手的出门率 = 自己出门的次数 / 自己的局数;
|
|
746
|
+
// 队伍的出门率 = 全队出门人次 / (局数 × 4),否则会出现 175% 这种数
|
|
747
|
+
const escapeDenom = kind === 'team' ? e.games * 4 : e.games
|
|
748
|
+
e.escapeRate = escapeDenom ? (e.escaped / escapeDenom) * 100 : 0
|
|
749
|
+
e.avgEscaped = div(e.escaped, e.games)
|
|
750
|
+
e.winRate = e.games ? (e.wins / e.games) * 100 : 0
|
|
751
|
+
}
|
|
752
|
+
// 默认排序:监管者看局均得分,求生者看局均修机(与 2.0 表头的默认排序一致)
|
|
753
|
+
const key = role === 'hunter' ? 'avgScore' : 'avgMachines'
|
|
754
|
+
rows.sort(function (a, b) {
|
|
755
|
+
return (b[key] - a[key]) || (b.games - a.games) || String(a.name).localeCompare(String(b.name), 'zh')
|
|
756
|
+
})
|
|
757
|
+
rows.forEach(function (e, i) { e.rank = i + 1 })
|
|
758
|
+
return rows
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function finalizeStanding(map, minGames) {
|
|
762
|
+
const gate = Math.max(1, Number(minGames) || 1)
|
|
763
|
+
const rows = Array.from(map.values()).filter(function (e) { return e.matches >= gate })
|
|
764
|
+
for (const e of rows) {
|
|
765
|
+
e.netRounds = e.roundsWin - e.roundsLoss
|
|
766
|
+
e.netScore = e.score - e.conceded
|
|
767
|
+
e.ratio = e.conceded ? e.score / e.conceded : 0
|
|
768
|
+
e.winRate = e.matches ? (e.wins / e.matches) * 100 : 0
|
|
769
|
+
}
|
|
770
|
+
// 胜场 → 净胜分 → 总分
|
|
771
|
+
rows.sort(function (a, b) {
|
|
772
|
+
return (b.wins - a.wins) || (b.netScore - a.netScore) || (b.score - a.score) ||
|
|
773
|
+
String(a.name).localeCompare(String(b.name), 'zh')
|
|
774
|
+
})
|
|
775
|
+
rows.forEach(function (e, i) { e.rank = i + 1 })
|
|
776
|
+
return rows
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* ---------------- HTML ---------------- */
|
|
780
|
+
|
|
781
|
+
const CSS = `
|
|
782
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
783
|
+
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;
|
|
784
|
+
background:linear-gradient(135deg,#0f172a 0%,#1e293b 50%,#0f172a 100%); color:#e2e8f0; padding:22px 18px; }
|
|
785
|
+
.page { max-width:__WIDTH__px; margin:0 auto; }
|
|
786
|
+
.card { background:rgba(30,41,59,.8); border:1px solid rgba(148,163,184,.15); border-radius:12px; overflow:hidden; }
|
|
787
|
+
.table-header { background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%); padding:16px 22px; }
|
|
788
|
+
.table-header h2 { font-size:20px; font-weight:800; color:#fff; letter-spacing:.5px; display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
789
|
+
.table-header .tour { font-size:15px; font-weight:600; color:rgba(255,255,255,.85); }
|
|
790
|
+
.table-header .sub { margin-top:7px; font-size:13px; color:rgba(255,255,255,.78); }
|
|
791
|
+
.ranking-table { width:100%; border-collapse:separate; border-spacing:0; }
|
|
792
|
+
.ranking-table thead th { white-space:nowrap; background:#1e293b; padding:11px 13px; font-size:11px; font-weight:700;
|
|
793
|
+
text-transform:uppercase; letter-spacing:.5px; color:#94a3b8; border-bottom:2px solid #334155; }
|
|
794
|
+
.ranking-table tbody td { white-space:nowrap; padding:10px 13px; font-size:13.5px; text-align:center;
|
|
795
|
+
border-bottom:1px solid rgba(51,65,85,.5); font-variant-numeric:tabular-nums; }
|
|
796
|
+
.ranking-table tbody tr:last-child td { border-bottom:none; }
|
|
797
|
+
.ranking-table tbody tr:nth-child(even) { background:rgba(148,163,184,.04); }
|
|
798
|
+
.ranking-table td.name { text-align:left; font-weight:700; color:#93c5fd; }
|
|
799
|
+
.ranking-table td.team { text-align:left; color:#cbd5e1; }
|
|
800
|
+
.rank-badge { display:inline-flex; align-items:center; justify-content:center; width:26px; height:26px;
|
|
801
|
+
border-radius:50%; font-weight:800; font-size:12px; background:rgba(148,163,184,.16); color:#cbd5e1; }
|
|
802
|
+
.rank-1 { background:linear-gradient(135deg,#fbbf24,#d97706); color:#1e293b; }
|
|
803
|
+
.rank-2 { background:linear-gradient(135deg,#e5e7eb,#9ca3af); color:#1e293b; }
|
|
804
|
+
.rank-3 { background:linear-gradient(135deg,#f59e0b,#b45309); color:#fff; }
|
|
805
|
+
.win { color:#3fb950; font-weight:700; }
|
|
806
|
+
.draw { color:#d29922; }
|
|
807
|
+
.loss { color:#f85149; }
|
|
808
|
+
.hl { color:#93c5fd; font-weight:700; }
|
|
809
|
+
.chips { display:flex; flex-wrap:wrap; gap:5px; justify-content:center; }
|
|
810
|
+
.chip-sm { font-size:12.5px; color:#e2e8f0; background:rgba(88,166,255,.08); border:1px solid rgba(88,166,255,.2);
|
|
811
|
+
border-radius:6px; padding:1px 7px; }
|
|
812
|
+
.chip-sm i { font-style:normal; color:#94a3b8; margin-left:3px; }
|
|
813
|
+
.none { color:#64748b; }
|
|
814
|
+
.foot { text-align:center; color:#94a3b8; font-size:12.5px; margin-top:14px; opacity:.8; }
|
|
815
|
+
.empty { padding:40px; text-align:center; color:#94a3b8; font-size:15px; }
|
|
816
|
+
`
|
|
817
|
+
|
|
818
|
+
function chips(list, n) {
|
|
819
|
+
if (!list || !list.length) return '<span class="none">-</span>'
|
|
820
|
+
return '<div class="chips">' + list.slice(0, n).map(function (x) {
|
|
821
|
+
return '<span class="chip-sm">' + esc(x[0]) + '<i>×' + x[1] + '</i></span>'
|
|
822
|
+
}).join('') + '</div>'
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function rankCell(r) {
|
|
826
|
+
const cls = r.rank <= 3 ? ' rank-' + r.rank : ''
|
|
827
|
+
return '<span class="rank-badge' + cls + '">' + r.rank + '</span>'
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function hunterPlayerTable(rows) {
|
|
831
|
+
const head = ['#', '监管者ID', '所属队伍', '局数', '胜', '平', '负', '四杀', '被四跑', '淘汰人数', '局均淘汰', '局均得分', 'MVP', '常用角色 Top3']
|
|
832
|
+
const body = rows.map(function (r) {
|
|
833
|
+
return '<tr>' +
|
|
834
|
+
'<td>' + rankCell(r) + '</td>' +
|
|
835
|
+
'<td class="name">' + esc(r.name) + '</td>' +
|
|
836
|
+
'<td class="team">' + esc(r.team || '-') + '</td>' +
|
|
837
|
+
'<td>' + r.games + '</td>' +
|
|
838
|
+
'<td class="' + (r.wins ? 'win' : '') + '">' + r.wins + '</td>' +
|
|
839
|
+
'<td class="' + (r.draws ? 'draw' : '') + '">' + r.draws + '</td>' +
|
|
840
|
+
'<td class="' + (r.losses ? 'loss' : '') + '">' + r.losses + '</td>' +
|
|
841
|
+
'<td>' + r.quadKills + '</td>' +
|
|
842
|
+
'<td>' + r.quadEscapes + '</td>' +
|
|
843
|
+
'<td>' + r.kills + '</td>' +
|
|
844
|
+
'<td class="hl">' + fixed(r.avgKills, 2) + '</td>' +
|
|
845
|
+
'<td class="hl">' + fixed(r.avgScore, 2) + '</td>' +
|
|
846
|
+
'<td>' + r.mvp + '</td>' +
|
|
847
|
+
'<td>' + chips(r.charsTop, 3) + '</td>' +
|
|
848
|
+
'</tr>'
|
|
849
|
+
}).join('')
|
|
850
|
+
return { head: head, body: body }
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function survivorPlayerTable(rows) {
|
|
854
|
+
const head = ['#', '求生者ID', '所属队伍', '局数', '胜', '平', '负', '被四杀', '四跑', '局均修机', '局均牵制', '局均救人', '局均砸板', '出门率', 'MVP', '常用角色 Top5']
|
|
855
|
+
const body = rows.map(function (r) {
|
|
856
|
+
return '<tr>' +
|
|
857
|
+
'<td>' + rankCell(r) + '</td>' +
|
|
858
|
+
'<td class="name">' + esc(r.name) + '</td>' +
|
|
859
|
+
'<td class="team">' + esc(r.team || '-') + '</td>' +
|
|
860
|
+
'<td>' + r.games + '</td>' +
|
|
861
|
+
'<td class="' + (r.wins ? 'win' : '') + '">' + r.wins + '</td>' +
|
|
862
|
+
'<td class="' + (r.draws ? 'draw' : '') + '">' + r.draws + '</td>' +
|
|
863
|
+
'<td class="' + (r.losses ? 'loss' : '') + '">' + r.losses + '</td>' +
|
|
864
|
+
'<td>' + r.quadKills + '</td>' +
|
|
865
|
+
'<td>' + r.quadEscapes + '</td>' +
|
|
866
|
+
'<td class="hl">' + fixed(r.avgMachines, 2) + '</td>' +
|
|
867
|
+
'<td>' + fixed(r.avgKite, 1) + '</td>' +
|
|
868
|
+
'<td>' + fixed(r.avgRescue, 2) + '</td>' +
|
|
869
|
+
'<td>' + fixed(r.avgBoard, 2) + '</td>' +
|
|
870
|
+
'<td class="hl">' + pct(r.escapeRate) + '%</td>' +
|
|
871
|
+
'<td>' + r.mvp + '</td>' +
|
|
872
|
+
'<td>' + chips(r.charsTop, 5) + '</td>' +
|
|
873
|
+
'</tr>'
|
|
874
|
+
}).join('')
|
|
875
|
+
return { head: head, body: body }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function hunterTeamTable(rows) {
|
|
879
|
+
const head = ['#', '队伍', '局数', '胜', '平', '负', '四杀', '被四跑', '淘汰人数', '局均淘汰', '局均得分', 'MVP', '常用角色 Top5']
|
|
880
|
+
const body = rows.map(function (r) {
|
|
881
|
+
return '<tr>' +
|
|
882
|
+
'<td>' + rankCell(r) + '</td>' +
|
|
883
|
+
'<td class="name">' + esc(r.name) + '</td>' +
|
|
884
|
+
'<td>' + r.games + '</td>' +
|
|
885
|
+
'<td class="' + (r.wins ? 'win' : '') + '">' + r.wins + '</td>' +
|
|
886
|
+
'<td class="' + (r.draws ? 'draw' : '') + '">' + r.draws + '</td>' +
|
|
887
|
+
'<td class="' + (r.losses ? 'loss' : '') + '">' + r.losses + '</td>' +
|
|
888
|
+
'<td>' + r.quadKills + '</td>' +
|
|
889
|
+
'<td>' + r.quadEscapes + '</td>' +
|
|
890
|
+
'<td>' + r.kills + '</td>' +
|
|
891
|
+
'<td class="hl">' + fixed(r.avgKills, 2) + '</td>' +
|
|
892
|
+
'<td class="hl">' + fixed(r.avgScore, 2) + '</td>' +
|
|
893
|
+
'<td>' + r.mvp + '</td>' +
|
|
894
|
+
'<td>' + chips(r.charsTop, 5) + '</td>' +
|
|
895
|
+
'</tr>'
|
|
896
|
+
}).join('')
|
|
897
|
+
return { head: head, body: body }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function survivorTeamTable(rows) {
|
|
901
|
+
const head = ['#', '队伍', '局数', '胜', '平', '负', '被四杀', '四跑', '局均修机', '局均牵制', '局均救人', '局均逃脱', '出门率', 'MVP', '常用角色 Top5']
|
|
902
|
+
const body = rows.map(function (r) {
|
|
903
|
+
return '<tr>' +
|
|
904
|
+
'<td>' + rankCell(r) + '</td>' +
|
|
905
|
+
'<td class="name">' + esc(r.name) + '</td>' +
|
|
906
|
+
'<td>' + r.games + '</td>' +
|
|
907
|
+
'<td class="' + (r.wins ? 'win' : '') + '">' + r.wins + '</td>' +
|
|
908
|
+
'<td class="' + (r.draws ? 'draw' : '') + '">' + r.draws + '</td>' +
|
|
909
|
+
'<td class="' + (r.losses ? 'loss' : '') + '">' + r.losses + '</td>' +
|
|
910
|
+
'<td>' + r.quadKills + '</td>' +
|
|
911
|
+
'<td>' + r.quadEscapes + '</td>' +
|
|
912
|
+
'<td class="hl">' + fixed(r.avgMachines, 2) + '</td>' +
|
|
913
|
+
'<td>' + fixed(r.avgKite, 1) + '</td>' +
|
|
914
|
+
'<td>' + fixed(r.avgRescue, 2) + '</td>' +
|
|
915
|
+
'<td>' + fixed(r.avgEscaped, 2) + '</td>' +
|
|
916
|
+
'<td class="hl">' + pct(r.escapeRate) + '%</td>' +
|
|
917
|
+
'<td>' + r.mvp + '</td>' +
|
|
918
|
+
'<td>' + chips(r.charsTop, 5) + '</td>' +
|
|
919
|
+
'</tr>'
|
|
920
|
+
}).join('')
|
|
921
|
+
return { head: head, body: body }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function standingTable(rows) {
|
|
925
|
+
const head = ['#', '队伍', '场数', '胜场', '负场', '平局', '胜局', '负局', '净胜局', '总分', '总失分', '净胜分', '得失比']
|
|
926
|
+
const body = rows.map(function (r) {
|
|
927
|
+
return '<tr>' +
|
|
928
|
+
'<td>' + rankCell(r) + '</td>' +
|
|
929
|
+
'<td class="name">' + esc(r.name) + '</td>' +
|
|
930
|
+
'<td>' + r.matches + '</td>' +
|
|
931
|
+
'<td class="' + (r.wins ? 'win' : '') + '">' + r.wins + '</td>' +
|
|
932
|
+
'<td class="' + (r.losses ? 'loss' : '') + '">' + r.losses + '</td>' +
|
|
933
|
+
'<td class="' + (r.ties ? 'draw' : '') + '">' + r.ties + '</td>' +
|
|
934
|
+
'<td>' + r.roundsWin + '</td>' +
|
|
935
|
+
'<td>' + r.roundsLoss + '</td>' +
|
|
936
|
+
'<td class="hl">' + (r.netRounds > 0 ? '+' : '') + r.netRounds + '</td>' +
|
|
937
|
+
'<td>' + r.score + '</td>' +
|
|
938
|
+
'<td>' + r.conceded + '</td>' +
|
|
939
|
+
'<td class="hl">' + (r.netScore > 0 ? '+' : '') + r.netScore + '</td>' +
|
|
940
|
+
'<td>' + fixed(r.ratio, 2) + '</td>' +
|
|
941
|
+
'</tr>'
|
|
942
|
+
}).join('')
|
|
943
|
+
return { head: head, body: body }
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const TABLE_BUILDERS = {
|
|
947
|
+
hunterPlayer: hunterPlayerTable,
|
|
948
|
+
survivorPlayer: survivorPlayerTable,
|
|
949
|
+
hunterTeam: hunterTeamTable,
|
|
950
|
+
survivorTeam: survivorTeamTable,
|
|
951
|
+
teamStanding: standingTable
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function buildDataHtml(type, data, opts) {
|
|
955
|
+
opts = opts || {}
|
|
956
|
+
const meta = TYPE_META[type]
|
|
957
|
+
const width = opts.width || 1300
|
|
958
|
+
const allRows = data[type] || []
|
|
959
|
+
const topN = Math.max(0, Number(opts.topN) || 0)
|
|
960
|
+
const rows = topN > 0 ? allRows.slice(0, topN) : allRows
|
|
961
|
+
const tour = data.tournament || {}
|
|
962
|
+
const builder = TABLE_BUILDERS[type]
|
|
963
|
+
|
|
964
|
+
const sub = [
|
|
965
|
+
'共 ' + (tour.matches || 0) + ' 场比赛 / ' + data.gameCount + ' 个半场 / ' +
|
|
966
|
+
allRows.length + (type === 'teamStanding' ? ' 支队伍' : ' 条记录'),
|
|
967
|
+
(topN > 0 && allRows.length > topN) ? '仅显示前 ' + topN + ' 名' : '',
|
|
968
|
+
data.minGames > 1 ? '仅统计 ≥' + data.minGames + ' 局' : '',
|
|
969
|
+
opts.updateTime ? '数据更新时间:' + opts.updateTime : '',
|
|
970
|
+
opts.mergeLabel ? '合并模式:' + opts.mergeLabel : ''
|
|
971
|
+
].filter(Boolean).join(' · ')
|
|
972
|
+
|
|
973
|
+
const table = rows.length
|
|
974
|
+
? (function () {
|
|
975
|
+
const t = builder(rows)
|
|
976
|
+
return '<table class="ranking-table"><thead><tr>' +
|
|
977
|
+
t.head.map(function (h) { return '<th>' + esc(h) + '</th>' }).join('') +
|
|
978
|
+
'</tr></thead><tbody>' + t.body + '</tbody></table>'
|
|
979
|
+
})()
|
|
980
|
+
: '<div class="empty">这个赛事没有可统计的数据</div>'
|
|
981
|
+
|
|
982
|
+
return '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n<meta charset="UTF-8">\n<style>' +
|
|
983
|
+
CSS.replace('__WIDTH__', String(width)) +
|
|
984
|
+
'</style>\n</head>\n<body>\n<div class="page">' +
|
|
985
|
+
'<div class="card">' +
|
|
986
|
+
'<div class="table-header">' +
|
|
987
|
+
'<h2>' + esc(meta.title) + '<span class="tour">' + esc(tour.key || '') + '</span></h2>' +
|
|
988
|
+
'<div class="sub">' + esc(sub) + '</div>' +
|
|
989
|
+
'</div>' +
|
|
990
|
+
table +
|
|
991
|
+
'</div>' +
|
|
992
|
+
'<div class="foot">数据来源 BWIKI 赛事数据展示 · 统计口径与 2.0 选手排名一致</div>' +
|
|
993
|
+
'</div>\n</body>\n</html>'
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* ---------------- 对外入口 ---------------- */
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* 生成「赛事 数据」的 HTML。
|
|
1000
|
+
* @returns {Promise<{html:string,width:number,type:string,tour:object,mergeLabel:string,updateTime:string}>}
|
|
1001
|
+
*/
|
|
1002
|
+
async function buildTournamentData(config, typeArg, tourArg, mergeMode) {
|
|
1003
|
+
const typeKey = TYPE_ALIAS[norm(typeArg)]
|
|
1004
|
+
if (!typeKey) {
|
|
1005
|
+
const err = new Error('未知的类型「' + norm(typeArg) + '」')
|
|
1006
|
+
err.code = 'BAD_TYPE'
|
|
1007
|
+
throw err
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const matches = await loadDataset(config)
|
|
1011
|
+
const tours = listTours(matches)
|
|
1012
|
+
if (!tours.length) {
|
|
1013
|
+
const err = new Error('赛事数据为空,请稍后再试')
|
|
1014
|
+
err.code = 'EMPTY_DATA'
|
|
1015
|
+
throw err
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let tour = null
|
|
1019
|
+
const wanted = norm(tourArg) || norm(config.matchDefaultTour)
|
|
1020
|
+
if (!wanted) {
|
|
1021
|
+
const err = new Error('还没有指定赛事')
|
|
1022
|
+
err.code = 'NO_TOUR'
|
|
1023
|
+
err.suggest = recentTours(tours, 6)
|
|
1024
|
+
throw err
|
|
1025
|
+
}
|
|
1026
|
+
tour = resolveTour(tours, wanted)
|
|
1027
|
+
if (!tour) {
|
|
1028
|
+
const err = new Error('未找到赛事「' + wanted + '」')
|
|
1029
|
+
err.code = 'TOUR_NOT_FOUND'
|
|
1030
|
+
err.suggest = recentTours(tours, 6)
|
|
1031
|
+
throw err
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const mode = norm(mergeMode) || 'team'
|
|
1035
|
+
const teamMapping = (mode === 'team' || mode === 'all') ? await loadTeamMapping(config) : {}
|
|
1036
|
+
const idMapping = (mode === 'player' || mode === 'all') ? await loadIdMapping(config) : {}
|
|
1037
|
+
|
|
1038
|
+
const data = computeTables(matches, tour, {
|
|
1039
|
+
mergeMode: mode,
|
|
1040
|
+
teamMapping: teamMapping,
|
|
1041
|
+
idMapping: idMapping,
|
|
1042
|
+
minGames: config.matchDataMinGames || 1
|
|
1043
|
+
})
|
|
1044
|
+
const updateTime = await loadAnnounceTime(config)
|
|
1045
|
+
const width = config.matchWidth || config.width || 1300
|
|
1046
|
+
const mergeLabel = { team: '合并队伍前身', player: '合并选手多id', all: '全部合并', none: '不合并' }[mode] || mode
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
html: buildDataHtml(typeKey, data, {
|
|
1050
|
+
width: width,
|
|
1051
|
+
updateTime: updateTime,
|
|
1052
|
+
mergeLabel: mergeLabel,
|
|
1053
|
+
topN: config.matchDataTopN
|
|
1054
|
+
}),
|
|
1055
|
+
width: width,
|
|
1056
|
+
type: typeKey,
|
|
1057
|
+
tour: tour,
|
|
1058
|
+
updateTime: updateTime,
|
|
1059
|
+
mergeLabel: mergeLabel,
|
|
1060
|
+
rows: data[typeKey].length,
|
|
1061
|
+
shown: (function () {
|
|
1062
|
+
const n = Math.max(0, Number(config.matchDataTopN) || 0)
|
|
1063
|
+
return n > 0 ? Math.min(n, data[typeKey].length) : data[typeKey].length
|
|
1064
|
+
})()
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function tourListForHelp() {
|
|
1069
|
+
return TYPE_HELP
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
module.exports = {
|
|
1073
|
+
loadFromDb: loadFromDb,
|
|
1074
|
+
loadDataset: loadDataset,
|
|
1075
|
+
TYPE_ALIAS: TYPE_ALIAS,
|
|
1076
|
+
TYPE_META: TYPE_META,
|
|
1077
|
+
TYPE_HELP: TYPE_HELP,
|
|
1078
|
+
listTours: listTours,
|
|
1079
|
+
resolveTour: resolveTour,
|
|
1080
|
+
computeTables: computeTables,
|
|
1081
|
+
buildDataHtml: buildDataHtml,
|
|
1082
|
+
buildTournamentData: buildTournamentData
|
|
1083
|
+
}
|