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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
+ }