koishi-plugin-dwrgbwiki 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.js +91 -13
  2. package/lib.js +642 -7
  3. package/package.json +1 -1
  4. package/readme.md +69 -1
package/index.js CHANGED
@@ -19,6 +19,9 @@ module.exports = {
19
19
  defaultPart: Schema.union(['8', '-127'])
20
20
  .description('默认段位(8=巅七,-127=全段位)')
21
21
  .default('8'),
22
+ mapDefaultCamp: Schema.union(['1', '2'])
23
+ .description('地图查询默认阵营(1=监管者,2=求生者)')
24
+ .default('1'),
22
25
  showBadge: Schema.boolean()
23
26
  .description('显示勋章分数据(获取失败时自动隐藏)')
24
27
  .default(true),
@@ -44,23 +47,98 @@ module.exports = {
44
47
  .example('胜率 小女孩 43 3 胜率')
45
48
  .action(async ({ session }, name, cond) => {
46
49
  if (!name || !name.trim()) return HELP
47
- if (!ctx.puppeteer) {
48
- return '未检测到 puppeteer 服务,请先安装并启用 koishi-plugin-puppeteer'
49
- }
50
- try {
51
- const result = await lib.collect(config, name.trim(), cond)
52
- if (!result.ok) return result.error
53
- const html = lib.buildHtml(result, { ...config, avatarBase: result.base })
54
- const image = await capture(ctx, html, config)
55
- return [h.image(image)]
56
- } catch (e) {
57
- ctx.logger('dwrgbwiki').warn(e)
58
- return '查询失败:' + (e && e.message ? e.message : e)
59
- }
50
+ return run(ctx, config, lib.collect, lib.buildHtml, name.trim(), cond)
51
+ })
52
+
53
+ ctx.command('地图 <地图名> [条件:text]', '查询地图胜率并生成图片')
54
+ .usage(MAP_HELP)
55
+ .example('地图 军工厂')
56
+ .example('地图 红教堂 S44 3 求生者')
57
+ .example('地图 月亮河公园 全赛季')
58
+ .action(async ({ session }, name, cond) => {
59
+ if (!name || !name.trim()) return MAP_HELP
60
+ return run(ctx, config, lib.collectMap, lib.buildMapHtml, name.trim(), cond)
61
+ })
62
+
63
+ ctx.command('模仿者 <角色名> [条件:text]', '查询模仿者游戏角色数据并生成图片')
64
+ .usage(MIMIC_HELP)
65
+ .example('模仿者 侦探')
66
+ .example('模仿者 药剂师 260531 低级场')
67
+ .action(async ({ session }, name, cond) => {
68
+ if (!name || !name.trim()) return MIMIC_HELP
69
+ return run(ctx, config, lib.collectMimic, lib.buildMimicHtml, name.trim(), cond)
70
+ })
71
+
72
+ ctx.command('勋章 <角色名> [条件:text]', '查询角色勋章分数线并生成图片')
73
+ .usage(BADGE_HELP)
74
+ .example('勋章 小女孩')
75
+ .example('勋章 小女孩 20250917 7日增长率')
76
+ .example('勋章 小女孩 赛季同比增长率')
77
+ .action(async ({ session }, name, cond) => {
78
+ if (!name || !name.trim()) return BADGE_HELP
79
+ return run(ctx, config, lib.collectBadge, lib.buildBadgeHtml, name.trim(), cond)
60
80
  })
61
81
  },
62
82
  }
63
83
 
84
+ async function run(ctx, config, collectFn, buildFn, name, cond) {
85
+ if (!ctx.puppeteer) {
86
+ return '未检测到 puppeteer 服务,请先安装并启用 koishi-plugin-puppeteer'
87
+ }
88
+ try {
89
+ const result = await collectFn(config, name, cond)
90
+ if (!result.ok) return result.error
91
+ const html = buildFn(result, { ...config, avatarBase: result.base })
92
+ const image = await capture(ctx, html, config)
93
+ return [h.image(image)]
94
+ } catch (e) {
95
+ ctx.logger('dwrgbwiki').warn(e)
96
+ return '查询失败:' + (e && e.message ? e.message : e)
97
+ }
98
+ }
99
+
100
+ const MAP_HELP = [
101
+ '查询地图胜率并生成图片',
102
+ '',
103
+ '用法:地图 <地图名> [赛季] [周数] [段位] [阵营]',
104
+ '',
105
+ '示例:',
106
+ ' 地图 军工厂',
107
+ ' 地图 红教堂 S44 3 求生者',
108
+ ' 地图 月亮河公园 全赛季 全段位',
109
+ '',
110
+ '阵营:监管者(默认)、求生者;赛季:S44 / 第44赛季;周数:第3周 / 3',
111
+ '段位:巅七(默认)、七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位',
112
+ '赛季、周数不填默认使用最新赛季最新周',
113
+ ].join('\n')
114
+
115
+ const MIMIC_HELP = [
116
+ '查询模仿者游戏角色数据并生成图片',
117
+ '',
118
+ '用法:模仿者 <角色名> [日期] [模式]',
119
+ '',
120
+ '示例:',
121
+ ' 模仿者 侦探',
122
+ ' 模仿者 药剂师 260531 低级场',
123
+ '',
124
+ '日期:YYMMDD(如 260531),不填默认最新日期',
125
+ '模式:低级场、10人高级场、12人高级场、所有模式(默认)',
126
+ ].join('\n')
127
+
128
+ const BADGE_HELP = [
129
+ '查询角色勋章分数线并生成图片',
130
+ '',
131
+ '用法:勋章 <角色名> [日期] [模式]',
132
+ '',
133
+ '示例:',
134
+ ' 勋章 小女孩',
135
+ ' 勋章 小女孩 20250917 7日增长率',
136
+ ' 勋章 小女孩 赛季同比增长率',
137
+ '',
138
+ '日期:YYYYMMDD(如 20250917),不填默认最新日期',
139
+ '模式:勋章分数(默认)、距平百分比、1/3/7/15/30日增长率、赛季同比增长率',
140
+ ].join('\n')
141
+
64
142
  async function capture(ctx, html, config) {
65
143
  const page = await ctx.puppeteer.page()
66
144
  try {
package/lib.js CHANGED
@@ -30,6 +30,24 @@ const PART_ORDER = [8, 7, 6, 5, 4, 3, 2, 1]
30
30
 
31
31
  const RATE_METRICS = ['win_rate_without_draw', 'win_rate', 'ping_rate', 'lose_rate', 'use_rate', 'ban_rate']
32
32
 
33
+ const MAP_CAMPS = { 1: '监管者', 2: '求生者' }
34
+ const MAP_CAMP_LABELS = Object.keys(MAP_CAMPS).reduce((m, k) => { m[MAP_CAMPS[k]] = k; return m }, {})
35
+
36
+ const MIMIC_MODES = { '67': '低级场', '68': '10人高级场', '73': '12人高级场', 'all_mode': '所有模式' }
37
+ const MIMIC_MODE_LABELS = Object.keys(MIMIC_MODES).reduce((m, k) => { m[MIMIC_MODES[k]] = k; return m }, {})
38
+
39
+ const BADGE_MODES = {
40
+ original: '勋章分数',
41
+ anomaly: '距平百分比',
42
+ '1d': '1日增长率',
43
+ '3d': '3日增长率',
44
+ '7d': '7日增长率',
45
+ '15d': '15日增长率',
46
+ '30d': '30日增长率',
47
+ season: '赛季同比增长率',
48
+ }
49
+ const BADGE_MODE_LABELS = Object.keys(BADGE_MODES).reduce((m, k) => { m[BADGE_MODES[k]] = k; return m }, {})
50
+
33
51
  const METRIC_LABELS = {}
34
52
  Object.keys(METRICS).forEach(k => { METRIC_LABELS[METRICS[k]] = k })
35
53
 
@@ -60,7 +78,7 @@ function winRateNoDraw(w, p) {
60
78
 
61
79
  function getMetricVal(item, metric, badgeScores) {
62
80
  if (metric.startsWith('badge_')) {
63
- const tier = metric.slice(5)
81
+ const tier = metric.slice(6)
64
82
  const s = badgeScores && badgeScores[item.name] ? badgeScores[item.name][tier] : null
65
83
  return s == null || isNaN(s) ? null : s
66
84
  }
@@ -292,6 +310,125 @@ async function loadBadgeAll(base, config, meta, dataFile) {
292
310
  }
293
311
  }
294
312
 
313
+ // ===== 地图数据 =====
314
+
315
+ async function loadMapData(base, timeout) {
316
+ const files = await fetchJson(url(base, '/胜率数据/map/mapdata.json'), timeout)
317
+ if (!Array.isArray(files)) throw new Error('地图文件列表解析失败')
318
+ const all = {}
319
+ for (const f of files) {
320
+ if (!/^MAP_S\d+\.json$/.test(f)) continue
321
+ let j
322
+ try { j = await fetchJson(url(base, '/胜率数据/map/' + f), timeout) } catch (e) { continue }
323
+ ;(j.data || []).forEach(item => {
324
+ const season = 'S' + item.season
325
+ const isFullSeason = item.week_num === null || item.week_num == -127
326
+ const weekKey = isFullSeason ? 'full_season' : 'W' + item.week_num
327
+ const partKey = String(item.part) === '-127' ? 'all_tiers' : 'tier' + item.part
328
+ const campId = String(item.camp_id)
329
+ const spaceName = item.space_name
330
+ if (!spaceName) return
331
+ if (!all[season]) all[season] = { end_time: '' }
332
+ if (!all[season][weekKey]) all[season][weekKey] = {}
333
+ if (isFullSeason && item.end_time && item.end_time > all[season].end_time) all[season].end_time = item.end_time
334
+ if (!all[season][weekKey][partKey]) all[season][weekKey][partKey] = {}
335
+ if (!all[season][weekKey][partKey][campId]) all[season][weekKey][partKey][campId] = {}
336
+ const slot = all[season][weekKey][partKey][campId]
337
+ if (!slot[spaceName]) slot[spaceName] = {
338
+ win_rate: { sum: 0, count: 0 }, ping_rate: { sum: 0, count: 0 },
339
+ fail_rate: { sum: 0, count: 0 }, space_no: item.space_no,
340
+ }
341
+ const s = slot[spaceName]
342
+ s.win_rate.sum += item.win_rate || 0; s.win_rate.count++
343
+ s.ping_rate.sum += item.ping_rate || 0; s.ping_rate.count++
344
+ s.fail_rate.sum += item.fail_rate || 0; s.fail_rate.count++
345
+ })
346
+ }
347
+ const out = {}
348
+ Object.keys(all).forEach(season => {
349
+ out[season] = { end_time: all[season].end_time }
350
+ Object.keys(all[season]).forEach(weekKey => {
351
+ if (weekKey === 'end_time') return
352
+ out[season][weekKey] = {}
353
+ Object.keys(all[season][weekKey]).forEach(partKey => {
354
+ out[season][weekKey][partKey] = {}
355
+ Object.keys(all[season][weekKey][partKey]).forEach(campId => {
356
+ const camp = {}
357
+ Object.keys(all[season][weekKey][partKey][campId]).forEach(mapName => {
358
+ const s = all[season][weekKey][partKey][campId][mapName]
359
+ camp[mapName] = {
360
+ win_rate: s.win_rate.count ? s.win_rate.sum / s.win_rate.count : null,
361
+ ping_rate: s.ping_rate.count ? s.ping_rate.sum / s.ping_rate.count : null,
362
+ fail_rate: s.fail_rate.count ? s.fail_rate.sum / s.fail_rate.count : null,
363
+ space_no: s.space_no,
364
+ }
365
+ })
366
+ out[season][weekKey][partKey][campId] = camp
367
+ })
368
+ })
369
+ })
370
+ })
371
+ return out
372
+ }
373
+
374
+ function parseMapArgs(rest) {
375
+ const tokens = String(rest || '').trim().split(/\s+/).filter(Boolean)
376
+ const r = { season: null, week: null, part: null, camp: null }
377
+ tokens.forEach(t => {
378
+ let m
379
+ if ((m = t.match(/^s\s*(\d{1,3})\s*赛季$/i))) { r.season = 'S' + m[1]; return }
380
+ if ((m = t.match(/^s\s*(\d{1,3})$/i))) { r.season = 'S' + m[1]; return }
381
+ if ((m = t.match(/^第?\s*(\d{1,3})\s*赛季$/i))) { r.season = 'S' + m[1]; return }
382
+ if ((m = t.match(/^第?\s*(\d{1,2})\s*周$/))) { r.week = m[1]; return }
383
+ if (MAP_CAMP_LABELS[t]) { r.camp = MAP_CAMP_LABELS[t]; return }
384
+ if ((m = t.match(/^([12])$/))) { r.camp = m[1]; return }
385
+ const pm = Object.keys(PARTS).find(k => PARTS[k] === t)
386
+ if (pm != null) { r.part = parseInt(pm); return }
387
+ if ((m = t.match(/^(\d{1,2})$/))) { r.week = m[1]; return }
388
+ })
389
+ return r
390
+ }
391
+
392
+ // ===== 模仿者游戏数据 =====
393
+
394
+ async function loadMimicFileList(base, timeout) {
395
+ const text = await fetchText(url(base, '/胜率数据/模仿者游戏/fileList.js'), timeout)
396
+ const m = text.match(/\[([\s\S]*)\]/)
397
+ if (!m) return []
398
+ let files = []
399
+ try { files = new Function('return [' + m[1] + ']')() } catch (e) { return [] }
400
+ return files.filter(f => typeof f === 'string' && /^data_\d{6}\.json$/.test(f)).sort()
401
+ }
402
+
403
+ async function loadMimicConfig(base, timeout) {
404
+ const j = await fetchJson(url(base, '/胜率数据/模仿者游戏/config.json'), timeout)
405
+ const nameToId = {}
406
+ Object.keys(j || {}).forEach(id => { const n = String(j[id]); if (n) nameToId[n] = id })
407
+ return { idToName: j || {}, nameToId }
408
+ }
409
+
410
+ function parseMimicArgs(rest) {
411
+ const tokens = String(rest || '').trim().split(/\s+/).filter(Boolean)
412
+ const r = { date: null, mode: null }
413
+ tokens.forEach(t => {
414
+ if (/^\d{6}$/.test(t)) { r.date = t; return }
415
+ if (MIMIC_MODE_LABELS[t]) { r.mode = MIMIC_MODE_LABELS[t]; return }
416
+ })
417
+ return r
418
+ }
419
+
420
+ // ===== 勋章数据 =====
421
+
422
+ function parseBadgeArgs(rest) {
423
+ const tokens = String(rest || '').trim().split(/\s+/).filter(Boolean)
424
+ const r = { date: null, mode: 'original' }
425
+ tokens.forEach(t => {
426
+ if (/^\d{8}$/.test(t)) { r.date = t; return }
427
+ if (BADGE_MODE_LABELS[t] != null) { r.mode = BADGE_MODE_LABELS[t]; return }
428
+ })
429
+ return r
430
+ }
431
+
295
432
  // ===== 数据计算 =====
296
433
 
297
434
  function uniqueBy(data, camp, part) {
@@ -396,6 +533,7 @@ function computeCard(opts) {
396
533
  return {
397
534
  entry, camp, part,
398
535
  rank: rank[entry.name] || 0, total,
536
+ ranking: sorted,
399
537
  six, allRanks, changeRanks, partsGrid, prevEntry,
400
538
  }
401
539
  }
@@ -447,6 +585,241 @@ function findSimilar(data, name) {
447
585
  return names.filter(n => n.includes(name) || name.includes(n)).slice(0, 8)
448
586
  }
449
587
 
588
+ // ===== 地图查询主流程 =====
589
+
590
+ async function collectMap(config, mapQuery, cond) {
591
+ const parsed = parseMapArgs(cond)
592
+ const src = await resolveSource(config)
593
+ const { base, meta } = src
594
+ const sourceLabel = base.replace(/^https?:\/\//, '')
595
+
596
+ const mapData = await loadMapData(base, config.timeout)
597
+ const seasons = Object.keys(mapData).filter(s => /^S\d+$/.test(s))
598
+ .sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)))
599
+ if (!seasons.length) return { ok: false, error: '地图数据为空' }
600
+
601
+ const seasonName = parsed.season && mapData[parsed.season] ? parsed.season : seasons[seasons.length - 1]
602
+ const seasonObj = mapData[seasonName]
603
+ const weekKeys = Object.keys(seasonObj).filter(w => w !== 'end_time')
604
+
605
+ let weekKey = 'full_season'
606
+ if (parsed.week != null) {
607
+ const wk = 'W' + parsed.week
608
+ if (!weekKeys.includes(wk)) return { ok: false, error: seasonName + ' 无第' + parsed.week + '周地图数据' }
609
+ weekKey = wk
610
+ } else {
611
+ const ws = weekKeys.filter(w => /^W\d+$/.test(w))
612
+ .sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)))
613
+ if (ws.length) weekKey = ws[ws.length - 1]
614
+ }
615
+
616
+ const part = parsed.part != null ? parsed.part : parseInt(config.defaultPart)
617
+ const partKey = part === -127 ? 'all_tiers' : 'tier' + part
618
+ const camp = parsed.camp != null ? parseInt(parsed.camp) : (config.mapDefaultCamp ? parseInt(config.mapDefaultCamp) : 1)
619
+
620
+ const slot = seasonObj[weekKey] && seasonObj[weekKey][partKey] && seasonObj[weekKey][partKey][camp]
621
+ if (!slot || !Object.keys(slot).length) {
622
+ return { ok: false, error: seasonName + ' ' + (weekKey === 'full_season' ? '全赛季' : '第' + weekKey.substring(1) + '周') + ' ' + (PARTS[part] || part) + ' ' + (MAP_CAMPS[camp] || camp) + ' 暂无地图数据' }
623
+ }
624
+
625
+ const mapNames = Object.keys(slot)
626
+ const exact = mapNames.find(n => n === mapQuery)
627
+ const match = exact || mapNames.find(n => n.includes(mapQuery) || mapQuery.includes(n))
628
+ if (!match) {
629
+ return { ok: false, error: '未找到地图「' + mapQuery + '」\n相近地图:' + mapNames.slice(0, 8).join('、') }
630
+ }
631
+
632
+ const cur = slot[match]
633
+ const nodraw = (cur.win_rate != null && cur.fail_rate != null && cur.win_rate + cur.fail_rate > 0)
634
+ ? cur.win_rate / (cur.win_rate + cur.fail_rate) * 100 : null
635
+
636
+ let prev = null
637
+ const seasonNum = parseInt(seasonName.substring(1))
638
+ const prevSeason = 'S' + (seasonNum - 1)
639
+ if (mapData[prevSeason]) {
640
+ const prevSlot = mapData[prevSeason][weekKey] && mapData[prevSeason][weekKey][partKey] && mapData[prevSeason][weekKey][partKey][camp]
641
+ const p = prevSlot && prevSlot[match]
642
+ if (p) {
643
+ const pn = (p.win_rate != null && p.fail_rate != null && p.win_rate + p.fail_rate > 0)
644
+ ? p.win_rate / (p.win_rate + p.fail_rate) * 100 : null
645
+ prev = { win_rate: p.win_rate, ping_rate: p.ping_rate, fail_rate: p.fail_rate, win_rate_nodraw: pn }
646
+ }
647
+ }
648
+
649
+ return {
650
+ ok: true, base, sourceLabel,
651
+ mapName: match, spaceNo: cur.space_no,
652
+ seasonName,
653
+ weekLabel: weekKey === 'full_season' ? '全赛季' : '第' + weekKey.substring(1) + '周',
654
+ partName: PARTS[part] || String(part),
655
+ campName: MAP_CAMPS[camp] || String(camp),
656
+ prevLabel: prev ? prevSeason + ' 同周' : null,
657
+ win_rate: cur.win_rate, ping_rate: cur.ping_rate, fail_rate: cur.fail_rate,
658
+ win_rate_nodraw: nodraw,
659
+ prev,
660
+ endTime: seasonObj.end_time || null,
661
+ }
662
+ }
663
+
664
+ // ===== 模仿者查询主流程 =====
665
+
666
+ async function collectMimic(config, name, cond) {
667
+ const parsed = parseMimicArgs(cond)
668
+ const src = await resolveSource(config)
669
+ const { base, meta } = src
670
+ const sourceLabel = base.replace(/^https?:\/\//, '')
671
+
672
+ const files = await loadMimicFileList(base, config.timeout)
673
+ if (!files.length) return { ok: false, error: '模仿者数据文件为空' }
674
+
675
+ let file
676
+ if (parsed.date) {
677
+ file = files.find(f => f.includes(parsed.date))
678
+ if (!file) {
679
+ return { ok: false, error: '未找到日期 ' + parsed.date + ' 的模仿者数据(可用 ' + files[0].substring(5, 11) + ' ~ ' + files[files.length - 1].substring(5, 11) + ')' }
680
+ }
681
+ } else {
682
+ file = files[files.length - 1]
683
+ }
684
+
685
+ const j = await fetchJson(url(base, '/胜率数据/模仿者游戏/json/' + file), config.timeout)
686
+ const allPlayerData = j.all_player_data || {}
687
+ const cfg = await loadMimicConfig(base, config.timeout)
688
+
689
+ const ids = Object.keys(cfg.idToName)
690
+ const exact = ids.find(id => cfg.idToName[id] === name)
691
+ let charId = exact
692
+ if (!charId) {
693
+ const fuzzy = ids.filter(id => cfg.idToName[id].includes(name) || name.includes(cfg.idToName[id]))
694
+ if (fuzzy.length === 1) charId = fuzzy[0]
695
+ else if (fuzzy.length > 1) {
696
+ return { ok: false, error: '角色名「' + name + '」匹配到多个角色:' + fuzzy.map(id => cfg.idToName[id]).join('、') }
697
+ }
698
+ }
699
+ if (!charId) {
700
+ return { ok: false, error: '未找到模仿者角色「' + name + '」\n可尝试:' + ids.map(id => cfg.idToName[id]).slice(0, 12).join('、') }
701
+ }
702
+
703
+ const campId = parseInt(charId)
704
+ const campName = campId < 200 ? '侦探团' : (campId < 300 ? '模仿者' : '中立')
705
+
706
+ const modes = []
707
+ Object.keys(MIMIC_MODES).forEach(modeKey => {
708
+ const md = allPlayerData[modeKey] && allPlayerData[modeKey][charId]
709
+ if (md) modes.push({ key: modeKey, label: MIMIC_MODES[modeKey], ...md })
710
+ })
711
+ if (!modes.length) return { ok: false, error: '角色「' + cfg.idToName[charId] + '」在该日期无数据' }
712
+
713
+ let primary = modes
714
+ let modeLabel = '所有模式'
715
+ if (parsed.mode) {
716
+ primary = modes.filter(m => m.key === parsed.mode)
717
+ if (!primary.length) return { ok: false, error: '模式「' + (MIMIC_MODES[parsed.mode] || parsed.mode) + '」无数据' }
718
+ modeLabel = MIMIC_MODES[parsed.mode]
719
+ }
720
+
721
+ return {
722
+ ok: true, base, sourceLabel, file,
723
+ name: cfg.idToName[charId], charId,
724
+ campName, campId,
725
+ dateLabel: '20' + file.substring(5, 7) + '.' + file.substring(7, 9) + '.' + file.substring(9, 11),
726
+ modes, primary: primary[0], modeLabel,
727
+ }
728
+ }
729
+
730
+ // ===== 勋章查询主流程 =====
731
+
732
+ async function collectBadge(config, name, cond) {
733
+ const parsed = parseBadgeArgs(cond)
734
+ const src = await resolveSource(config)
735
+ const { base, meta } = src
736
+ const sourceLabel = base.replace(/^https?:\/\//, '')
737
+
738
+ const files = await loadBadgeFiles(base, config.timeout)
739
+ if (!files.length) return { ok: false, error: '勋章数据文件为空' }
740
+
741
+ let curDate = parsed.date
742
+ if (!curDate) {
743
+ curDate = files[files.length - 1].replace('Badge_', '').replace('.json', '')
744
+ }
745
+ const curFile = 'Badge_' + curDate + '.json'
746
+ if (!files.includes(curFile)) {
747
+ return { ok: false, error: '未找到日期 ' + curDate + ' 的勋章数据(最新 ' + files[files.length - 1].replace('Badge_', '').replace('.json', '') + ')' }
748
+ }
749
+
750
+ const cur = await loadBadgeMap(base, curFile, config.timeout)
751
+ const campNames = ['', '监管者', '求生者']
752
+
753
+ const names = Object.keys(cur.map)
754
+ const exact = names.find(n => n === name)
755
+ let heroName = exact
756
+ if (!heroName) {
757
+ const fuzzy = names.filter(n => n.includes(name) || name.includes(n))
758
+ if (fuzzy.length === 1) heroName = fuzzy[0]
759
+ else if (fuzzy.length > 1) {
760
+ return { ok: false, error: '角色名「' + name + '」匹配到多个角色:' + fuzzy.join('、') }
761
+ }
762
+ }
763
+ if (!heroName) return { ok: false, error: '未找到角色「' + name + '」\n相近角色:' + names.slice(0, 8).join('、') }
764
+
765
+ const campId = parseInt(cur.camps[heroName]) || 2
766
+ const curScores = cur.map[heroName] || {}
767
+
768
+ const delta = {}
769
+ let prevDate = null
770
+ if (parsed.mode === 'anomaly') {
771
+ BADGE_TIERS.forEach(t => {
772
+ const cv = curScores[t.key]
773
+ let sum = 0, cnt = 0
774
+ Object.keys(cur.map).forEach(nm => {
775
+ if (cur.camps[nm] !== String(campId)) return
776
+ const v = cur.map[nm][t.key]
777
+ if (v) { sum += v; cnt++ }
778
+ })
779
+ const avg = cnt ? sum / cnt : null
780
+ delta[t.key] = avg > 0 && cv != null ? (cv - avg) / avg * 100 : null
781
+ })
782
+ } else if (parsed.mode === 'season') {
783
+ prevDate = seasonComparisonDate(parseInt(curDate), meta.seasonList)
784
+ const prevFile = prevDate ? pickBadgeFile(files, prevDate, 30) : null
785
+ if (prevFile) {
786
+ const prev = await loadBadgeMap(base, prevFile, config.timeout)
787
+ const p = prev.map[heroName] || {}
788
+ BADGE_TIERS.forEach(t => {
789
+ delta[t.key] = p[t.key] > 0 && curScores[t.key] != null ? (curScores[t.key] - p[t.key]) / p[t.key] * 100 : null
790
+ })
791
+ prevDate = prevFile.replace('Badge_', '').replace('.json', '')
792
+ }
793
+ } else if (/^\d+d$/.test(parsed.mode)) {
794
+ const days = parseInt(parsed.mode)
795
+ const prevTarget = dateAddDays(parseInt(curDate), -days)
796
+ const prevFile = pickBadgeFile(files, prevTarget, 3)
797
+ if (prevFile) {
798
+ const prev = await loadBadgeMap(base, prevFile, config.timeout)
799
+ const p = prev.map[heroName] || {}
800
+ BADGE_TIERS.forEach(t => {
801
+ delta[t.key] = p[t.key] > 0 && curScores[t.key] != null ? (curScores[t.key] - p[t.key]) / p[t.key] * 100 : null
802
+ })
803
+ prevDate = prevFile.replace('Badge_', '').replace('.json', '')
804
+ }
805
+ }
806
+
807
+ const fmtDate = d => d ? String(d).substring(0, 4) + '.' + String(d).substring(4, 6) + '.' + String(d).substring(6, 8) : null
808
+
809
+ return {
810
+ ok: true, base, sourceLabel, file: curFile,
811
+ name: heroName, campName: campNames[campId] || '未知', campId,
812
+ dateLabel: fmtDate(curDate),
813
+ mode: parsed.mode, modeLabel: BADGE_MODES[parsed.mode] || parsed.mode,
814
+ tiers: BADGE_TIERS.map(t => ({
815
+ label: t.label, cls: t.cls,
816
+ val: curScores[t.key] != null ? curScores[t.key] : null,
817
+ delta: delta[t.key] != null ? delta[t.key] : null,
818
+ })),
819
+ prevDate: fmtDate(prevDate),
820
+ }
821
+ }
822
+
450
823
  // ===== 主流程 =====
451
824
 
452
825
  async function collect(config, name, cond) {
@@ -531,7 +904,6 @@ function buildHtml(data, config) {
531
904
 
532
905
  const hl = getMetricVal(card.entry, metric, data.badgeScores)
533
906
  const hlCls = metric.startsWith('badge_') ? 'plain' : (hl == null ? '' : (hl >= 0.5 ? 'high' : (hl >= 0.3 ? 'mid' : 'low')))
534
- const rankSup = '#' + card.rank + '/' + card.total
535
907
 
536
908
  const sixRows = card.six.map(m => {
537
909
  const rk = card.allRanks[m.key][card.entry.name]
@@ -588,9 +960,11 @@ function buildHtml(data, config) {
588
960
  '<div class="card-badge-tiers">' + tierItems + '</div></div>' +
589
961
  growBlock('赛季同比增长 (' + short(badge.dates.prev) + '→' + short(badge.dates.cur) + ')', growItems(badge.season)) +
590
962
  growBlock(badgeDays + '日增长率 (' + short(badge.dates.daily) + '→' + short(badge.dates.cur) + ')', growItems(badge.daily)) +
591
- growBlock('距阵营平均值', growItems(badge.anomaly))
963
+ growBlock('距阵营平均值', growItems(badge.anomaly)) +
964
+ '<div class="cmd-hint">' + esc(buildHelp()) + '</div>'
592
965
  } else {
593
- zoneRight = '<div class="stat-block"><div class="comp-title" style="color:var(--text-secondary)">暂无勋章数据</div></div>'
966
+ zoneRight = '<div class="stat-block"><div class="comp-title" style="color:var(--text-secondary)">暂无勋章数据</div></div>' +
967
+ '<div class="cmd-hint">' + esc(buildHelp()) + '</div>'
594
968
  }
595
969
 
596
970
  const position = card.entry.position
@@ -598,6 +972,14 @@ function buildHtml(data, config) {
598
972
  const avatarUrl = avatar ? avatar.replace(/\/+$/, '') + '/statics/角色头像/' + encodeURIComponent(name) + '.png' : ''
599
973
  const campName = card.camp === 1 ? '监管者' : '求生者'
600
974
 
975
+ const rankRows = buildRankRows(card, metric, data.badgeScores)
976
+ const rankBlock = rankRows.length
977
+ ? '<div class="zone-sep"></div>' +
978
+ '<div class="comp-title" style="color:var(--accent);margin-bottom:4px">本阵营排名(' + metricLabel + ')</div>' +
979
+ '<div class="rank-table"><div class="rank-row rank-header"><span class="rk-pos">排名</span><span class="rk-name">角色名</span><span class="rk-val">数据</span></div>' +
980
+ rankRows.join('') + '</div>'
981
+ : ''
982
+
601
983
  const zoneCenter =
602
984
  '<div class="card-avatar-wrap">' +
603
985
  (avatarUrl
@@ -607,8 +989,9 @@ function buildHtml(data, config) {
607
989
  '</div>' +
608
990
  '<div class="card-char-name">' + esc(name) + '</div>' +
609
991
  (position ? '<div class="card-sub">' + esc(position) + ' · ' + campName + '</div>' : '') +
610
- '<div class="card-highlight ' + hlCls + '">' + fmtHigh(hl, metric) + '<sup class="hl-rank">' + rankSup + '</sup></div>' +
611
- '<div class="card-highlight-label">' + metricLabel + ' · ' + partName + '</div>'
992
+ '<div class="card-highlight ' + hlCls + '">' + fmtHigh(hl, metric) + '</div>' +
993
+ '<div class="card-highlight-label">' + metricLabel + ' · ' + partName + ' · #' + card.rank + '/' + card.total + '</div>' +
994
+ rankBlock
612
995
 
613
996
  const titleBar =
614
997
  '<span>' + seasonName + '</span><span class="sep">·</span>' +
@@ -642,8 +1025,17 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
642
1025
  .card-highlight.mid { color:var(--yellow); }
643
1026
  .card-highlight.low { color:var(--red); }
644
1027
  .card-highlight.plain { color:var(--accent); }
645
- .card-highlight .hl-rank { position:absolute; top:-0.15em; font-size:.35em; font-weight:700; color:var(--accent); white-space:nowrap; }
646
1028
  .card-highlight-label { font-size:15px; color:var(--text-secondary); }
1029
+ .rank-table { width:100%; border:1px solid rgba(255,255,255,.08); border-radius:8px; overflow:hidden; }
1030
+ .rank-row { display:grid; grid-template-columns:48px 1fr 84px; gap:6px; align-items:center; padding:8px 12px; background:rgba(255,255,255,.02); border-bottom:1px solid rgba(255,255,255,.05); font-size:16px; }
1031
+ .rank-row:last-child { border-bottom:none; }
1032
+ .rank-row.self { background:rgba(88,166,255,.12); border-left:3px solid var(--accent); font-weight:700; }
1033
+ .rank-header { background:rgba(255,255,255,.05); font-size:13px; color:var(--text-secondary); font-weight:600; }
1034
+ .rk-pos { color:var(--accent); font-weight:700; font-variant-numeric:tabular-nums; }
1035
+ .rank-row.self .rk-pos { color:var(--yellow); }
1036
+ .rk-name { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1037
+ .rk-val { text-align:right; font-variant-numeric:tabular-nums; font-weight:600; font-size:16px; }
1038
+ .rank-header .rk-pos, .rank-header .rk-val { color:var(--text-secondary); font-weight:600; }
647
1039
  .stat-block { display:flex; flex-direction:column; gap:6px; }
648
1040
  .stat-block + .stat-block { margin-top:12px; }
649
1041
  .s-row { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:6px; gap:10px; }
@@ -671,6 +1063,7 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
671
1063
  .card-badge-item.tier-b .b-tier { color:var(--accent); }
672
1064
  .card-badge-item.tier-c .b-tier { color:var(--text-secondary); }
673
1065
  .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1066
+ .cmd-hint { margin-top:16px; padding:8px 12px; border:1px solid rgba(255,255,255,.08); border-radius:8px; background:rgba(22,27,34,.5); white-space:pre-wrap; font-family:inherit; font-size:10px; line-height:1.55; color:var(--accent); }
674
1067
  </style>
675
1068
  </head>
676
1069
  <body>
@@ -695,6 +1088,236 @@ function fmtHigh(v, metric) {
695
1088
  return (v * 100).toFixed(2) + '%'
696
1089
  }
697
1090
 
1091
+ function buildRankRows(card, metric, badgeScores) {
1092
+ const { rank, total, ranking } = card
1093
+ if (!rank || !total || !ranking || !ranking.length) return []
1094
+ const lo = Math.max(1, rank - 5)
1095
+ const hi = Math.min(total, rank + 5)
1096
+ const list = []
1097
+ if (1 < lo || 1 > hi) list.push(1)
1098
+ for (let i = lo; i <= hi; i++) list.push(i)
1099
+ if (total !== hi && total !== 1) list.push(total)
1100
+ return list.map(r => {
1101
+ const item = ranking[r - 1]
1102
+ if (!item) return ''
1103
+ const v = getMetricVal(item, metric, badgeScores)
1104
+ const self = r === rank
1105
+ return '<div class="rank-row' + (self ? ' self' : '') + '">' +
1106
+ '<span class="rk-pos">#' + r + '</span>' +
1107
+ '<span class="rk-name">' + esc(item.name) + '</span>' +
1108
+ '<span class="rk-val">' + fmtHigh(v, metric) + '</span></div>'
1109
+ })
1110
+ }
1111
+
1112
+ // ===== 地图 HTML =====
1113
+
1114
+ function buildMapHtml(data, config) {
1115
+ const width = config.width || 1000
1116
+ const d = (v, suffix) => v == null || isNaN(v) ? '--' : v.toFixed(2) + suffix
1117
+ const diffHtml = (cur, prev) => {
1118
+ if (cur == null || prev == null) return '<span class="stat-delta">--</span>'
1119
+ const diff = cur - prev
1120
+ const cls = Math.abs(diff) < 0.01 ? '' : (diff > 0 ? 'up' : 'down')
1121
+ return '<span class="stat-delta ' + cls + '">' + (diff > 0 ? '+' : '') + diff.toFixed(2) + '%</span>'
1122
+ }
1123
+
1124
+ const rows = [
1125
+ { label: '胜率', cur: data.win_rate, prev: data.prev ? data.prev.win_rate : null },
1126
+ { label: '平率', cur: data.ping_rate, prev: data.prev ? data.prev.ping_rate : null },
1127
+ { label: '负率', cur: data.fail_rate, prev: data.prev ? data.prev.fail_rate : null },
1128
+ { label: '去平胜率', cur: data.win_rate_nodraw, prev: data.prev ? data.prev.win_rate_nodraw : null },
1129
+ ].map(r =>
1130
+ '<div class="s-row"><span class="s-label">' + r.label + '</span>' +
1131
+ '<span class="s-val">' + d(r.cur, '%') + '</span>' +
1132
+ '<span class="s-rank" style="text-align:right;min-width:110px">' + (data.prev ? diffHtml(r.cur, r.prev) : '<span class="stat-delta">--</span>') + '</span></div>'
1133
+ ).join('')
1134
+
1135
+ const prevBlock = data.prev
1136
+ ? '<div class="zone-sep"></div><div class="comp-title" style="color:var(--accent);margin-bottom:4px">对比上赛季(' + data.prevLabel + ')</div>'
1137
+ : ''
1138
+
1139
+ const titleBar =
1140
+ '<span>' + data.seasonName + '</span><span class="sep">·</span>' +
1141
+ '<span>' + data.weekLabel + '</span><span class="sep">·</span>' +
1142
+ '<span>' + data.partName + '</span><span class="sep">·</span>' +
1143
+ '<span>' + data.campName + '</span>'
1144
+
1145
+ const spaceNo = data.spaceNo != null ? '<div class="card-sub">地图编号 ' + esc(String(data.spaceNo)) + '</div>' : ''
1146
+
1147
+ return `<!DOCTYPE html>
1148
+ <html lang="zh-CN">
1149
+ <head>
1150
+ <meta charset="UTF-8">
1151
+ <style>
1152
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1153
+ * { margin:0; padding:0; box-sizing:border-box; }
1154
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1155
+ .title-bar { display:flex; justify-content:center; align-items:center; gap:8px; padding:6px 18px; background:rgba(22,27,34,.9); border:1px solid var(--border); border-radius:20px; font-size:20px; margin-bottom:14px; white-space:nowrap; }
1156
+ .title-bar .sep { color:var(--border); }
1157
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1158
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1159
+ .card-map-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 2px; }
1160
+ .card-sub { font-size:13px; color:var(--text-secondary); margin-bottom:10px; }
1161
+ .stat-block { display:flex; flex-direction:column; gap:6px; }
1162
+ .s-row { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:6px; gap:10px; }
1163
+ .s-label { font-size:15px; color:var(--text-secondary); font-weight:600; width:90px; flex-shrink:0; }
1164
+ .s-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; flex:1; text-align:center; }
1165
+ .s-rank { font-size:15px; min-width:110px; text-align:right; }
1166
+ .stat-delta { font-size:14px; font-weight:600; font-variant-numeric:tabular-nums; }
1167
+ .stat-delta.up { color:var(--green); }
1168
+ .stat-delta.down { color:var(--red); }
1169
+ .zone-sep { margin:12px 0; border-top:1px solid rgba(255,255,255,.08); }
1170
+ .comp-title { font-size:15px; color:var(--green); font-weight:700; text-align:center; margin-bottom:8px; letter-spacing:1px; }
1171
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1172
+ </style>
1173
+ </head>
1174
+ <body>
1175
+ <div class="title-bar">${titleBar}</div>
1176
+ <div class="card">
1177
+ <div class="zone-center">
1178
+ <div class="card-map-name">${esc(data.mapName)}</div>
1179
+ ${spaceNo}
1180
+ </div>
1181
+ <div class="stat-block">${rows}</div>
1182
+ ${prevBlock}
1183
+ </div>
1184
+ <div class="foot">数据来源:${esc(data.sourceLabel)} · 数据均根据第五人格官方小程序数据计算</div>
1185
+ </body>
1186
+ </html>`
1187
+ }
1188
+
1189
+ // ===== 模仿者 HTML =====
1190
+
1191
+ function buildMimicHtml(data, config) {
1192
+ const pct = v => v == null || isNaN(v) ? '--' : (v * 100).toFixed(2) + '%'
1193
+ const p = data.primary
1194
+ const rows = [
1195
+ { label: '胜率', val: pct(p.win_rate) },
1196
+ { label: '出场率', val: pct(p.show_rate) },
1197
+ { label: '投票正确率', val: pct(p.vote_correct_rate) },
1198
+ { label: '被杀率', val: pct(p.bekill_rate) },
1199
+ ].map(r =>
1200
+ '<div class="s-row"><span class="s-label">' + r.label + '</span>' +
1201
+ '<span class="s-val">' + r.val + '</span><span class="s-rank"></span></div>'
1202
+ ).join('')
1203
+
1204
+ const otherModes = data.modes.map(m => {
1205
+ const isPrimary = m.key === p.key
1206
+ return '<div class="mode-item' + (isPrimary ? ' active' : '') + '">' +
1207
+ '<span class="mode-name">' + esc(m.label) + '</span>' +
1208
+ '<span class="mode-val">' + pct(m.win_rate) + '</span></div>'
1209
+ }).join('')
1210
+
1211
+ const titleBar =
1212
+ '<span>' + data.dateLabel + '</span><span class="sep">·</span>' +
1213
+ '<span>' + data.modeLabel + '</span><span class="sep">·</span>' +
1214
+ '<span>' + data.campName + '</span>'
1215
+
1216
+ return `<!DOCTYPE html>
1217
+ <html lang="zh-CN">
1218
+ <head>
1219
+ <meta charset="UTF-8">
1220
+ <style>
1221
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1222
+ * { margin:0; padding:0; box-sizing:border-box; }
1223
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1224
+ .title-bar { display:flex; justify-content:center; align-items:center; gap:8px; padding:6px 18px; background:rgba(22,27,34,.9); border:1px solid var(--border); border-radius:20px; font-size:20px; margin-bottom:14px; white-space:nowrap; }
1225
+ .title-bar .sep { color:var(--border); }
1226
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1227
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1228
+ .card-char-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 2px; }
1229
+ .card-camp { font-size:13px; color:var(--accent); margin-bottom:12px; }
1230
+ .stat-block { display:flex; flex-direction:column; gap:6px; }
1231
+ .s-row { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:6px; gap:10px; }
1232
+ .s-label { font-size:15px; color:var(--text-secondary); font-weight:600; width:110px; flex-shrink:0; }
1233
+ .s-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; flex:1; text-align:center; }
1234
+ .s-rank { min-width:46px; }
1235
+ .zone-sep { margin:12px 0; border-top:1px solid rgba(255,255,255,.08); }
1236
+ .comp-title { font-size:15px; color:var(--green); font-weight:700; text-align:center; margin-bottom:8px; letter-spacing:1px; }
1237
+ .mode-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:6px; }
1238
+ .mode-item { display:flex; align-items:center; justify-content:space-between; padding:6px 10px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:5px; }
1239
+ .mode-item.active { border-color:var(--accent); }
1240
+ .mode-name { font-size:13px; color:var(--text-secondary); font-weight:600; }
1241
+ .mode-val { font-size:15px; font-weight:700; font-variant-numeric:tabular-nums; }
1242
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1243
+ </style>
1244
+ </head>
1245
+ <body>
1246
+ <div class="title-bar">${titleBar}</div>
1247
+ <div class="card">
1248
+ <div class="zone-center">
1249
+ <div class="card-char-name">${esc(data.name)}</div>
1250
+ <div class="card-camp">${esc(data.campName)}</div>
1251
+ </div>
1252
+ <div class="stat-block">${rows}</div>
1253
+ <div class="zone-sep"></div>
1254
+ <div class="comp-title" style="color:var(--accent);margin-bottom:4px">各模式胜率</div>
1255
+ <div class="mode-grid">${otherModes}</div>
1256
+ </div>
1257
+ <div class="foot">数据来源:${esc(data.sourceLabel)} · 数据文件:${esc(data.file)}</div>
1258
+ </body>
1259
+ </html>`
1260
+ }
1261
+
1262
+ // ===== 勋章 HTML =====
1263
+
1264
+ function buildBadgeHtml(data, config) {
1265
+ const tierItems = data.tiers.map(t => {
1266
+ const deltaHtml = data.mode === 'original'
1267
+ ? ''
1268
+ : '<div class="b-delta ' + (t.delta == null ? '' : (t.delta > 0 ? 'up' : (t.delta < 0 ? 'down' : ''))) + '">' +
1269
+ (t.delta == null ? '--' : (t.delta > 0 ? '+' : '') + t.delta.toFixed(1) + '%') + '</div>'
1270
+ return '<div class="card-badge-item ' + t.cls + '"><span class="b-tier">' + t.label + '</span>' +
1271
+ '<span class="b-val">' + (t.val != null ? t.val : '--') + '</span>' + deltaHtml + '</div>'
1272
+ }).join('')
1273
+
1274
+ const titleBar =
1275
+ '<span>' + data.dateLabel + '</span><span class="sep">·</span>' +
1276
+ '<span>' + data.modeLabel + '</span><span class="sep">·</span>' +
1277
+ '<span>' + data.campName + '</span>'
1278
+
1279
+ const footNote = data.prevDate && data.mode !== 'original'
1280
+ ? '<div class="foot">对比日期:' + data.prevDate + ' · 数据来源:' + esc(data.sourceLabel) + '</div>'
1281
+ : '<div class="foot">数据来源:' + esc(data.sourceLabel) + '</div>'
1282
+
1283
+ return `<!DOCTYPE html>
1284
+ <html lang="zh-CN">
1285
+ <head>
1286
+ <meta charset="UTF-8">
1287
+ <style>
1288
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1289
+ * { margin:0; padding:0; box-sizing:border-box; }
1290
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1291
+ .title-bar { display:flex; justify-content:center; align-items:center; gap:8px; padding:6px 18px; background:rgba(22,27,34,.9); border:1px solid var(--border); border-radius:20px; font-size:20px; margin-bottom:14px; white-space:nowrap; }
1292
+ .title-bar .sep { color:var(--border); }
1293
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1294
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1295
+ .card-char-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 8px; }
1296
+ .card-badge-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }
1297
+ .card-badge-item { display:flex; flex-direction:column; align-items:center; gap:3px; padding:8px 3px; background:rgba(255,255,255,.02); border:1px solid rgba(255,255,255,.04); border-radius:5px; }
1298
+ .b-tier { font-size:13px; color:var(--text-secondary); font-weight:700; }
1299
+ .b-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; }
1300
+ .b-delta { font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; }
1301
+ .b-delta.up { color:var(--green); }
1302
+ .b-delta.down { color:var(--red); }
1303
+ .card-badge-item.tier-s .b-tier { color:var(--yellow); }
1304
+ .card-badge-item.tier-a .b-tier { color:var(--green); }
1305
+ .card-badge-item.tier-b .b-tier { color:var(--accent); }
1306
+ .card-badge-item.tier-c .b-tier { color:var(--text-secondary); }
1307
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1308
+ </style>
1309
+ </head>
1310
+ <body>
1311
+ <div class="title-bar">${titleBar}</div>
1312
+ <div class="card">
1313
+ <div class="zone-center"><div class="card-char-name">${esc(data.name)}</div></div>
1314
+ <div class="card-badge-grid">${tierItems}</div>
1315
+ </div>
1316
+ ${footNote}
1317
+ </body>
1318
+ </html>`
1319
+ }
1320
+
698
1321
  function buildHelp() {
699
1322
  return [
700
1323
  '查询角色胜率并生成图片',
@@ -720,10 +1343,22 @@ module.exports = {
720
1343
  BADGE_TIERS,
721
1344
  PARTS,
722
1345
  PART_ORDER,
1346
+ MAP_CAMPS,
1347
+ MIMIC_MODES,
1348
+ BADGE_MODES,
723
1349
  parseArgs,
1350
+ parseMapArgs,
1351
+ parseMimicArgs,
1352
+ parseBadgeArgs,
724
1353
  resolveSource,
725
1354
  collect,
1355
+ collectMap,
1356
+ collectMimic,
1357
+ collectBadge,
726
1358
  buildHtml,
1359
+ buildMapHtml,
1360
+ buildMimicHtml,
1361
+ buildBadgeHtml,
727
1362
  buildHelp,
728
1363
  findSimilar,
729
1364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "第五人格BWIKI编辑组测试用组件,纯AI生成喵",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  第五人格BWIKI编辑组测试用组件,纯AI生成喵。
4
4
 
5
- 查询第五人格角色胜率,返回一张图片(样式参考 胜率数据站 animationshow.html 的角色卡片)。
5
+ 查询第五人格角色胜率、地图胜率、模仿者游戏数据、勋章分数线,返回一张图片(样式参考 胜率数据站 animationshow.html 的角色卡片)。
6
6
 
7
7
  需要安装 [koishi-plugin-puppeteer](https://www.npmjs.com/package/koishi-plugin-puppeteer) 用于截图。
8
8
 
@@ -10,8 +10,13 @@
10
10
 
11
11
  ```
12
12
  胜率 <角色名> [赛季] [周数] [排序要素] [段位]
13
+ 地图 <地图名> [赛季] [周数] [段位] [阵营]
14
+ 模仿者 <角色名> [日期] [模式]
15
+ 勋章 <角色名> [日期] [模式]
13
16
  ```
14
17
 
18
+ ### 胜率(角色胜率)
19
+
15
20
  示例:
16
21
 
17
22
  | 指令 | 含义 |
@@ -26,6 +31,48 @@
26
31
  - 排序要素默认 `去平胜率`,可选:胜率、平率、负率、使用率、禁用率、S1/S10/A11/A100/B101/B300/C301/C500 勋章分
27
32
  - 段位默认 `巅七`,可选:七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位
28
33
 
34
+ ### 地图(地图胜率)
35
+
36
+ 示例:
37
+
38
+ | 指令 | 含义 |
39
+ | --- | --- |
40
+ | `地图 军工厂` | 最新赛季最新周,监管者视角 |
41
+ | `地图 红教堂 S44 3 求生者` | 指定赛季、周数与阵营 |
42
+ | `地图 月亮河公园 全赛季 全段位` | 赛季累计与全段位 |
43
+ | `地图 湖景` | 支持模糊匹配地图名 |
44
+
45
+ - 阵营默认 `监管者`,可选:求生者;赛季:S44 / 第44赛季;周数:第3周 / 3
46
+ - 段位默认 `巅七`,可选:七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位
47
+ - 显示胜率/平率/负率/去平胜率,以及对比上赛季同周的变化
48
+
49
+ ### 模仿者(模仿者游戏数据)
50
+
51
+ 示例:
52
+
53
+ | 指令 | 含义 |
54
+ | --- | --- |
55
+ | `模仿者 侦探` | 最新日期,所有模式 |
56
+ | `模仿者 药剂师 260531 12人高级场` | 指定日期与模式 |
57
+
58
+ - 日期:YYMMDD(如 260531),不填默认最新日期
59
+ - 模式:低级场、10人高级场、12人高级场、所有模式(默认)
60
+ - 显示胜率/出场率/投票正确率/被杀率,以及各模式胜率对比;角色阵营分为侦探团/模仿者/中立
61
+
62
+ ### 勋章(角色勋章分数线)
63
+
64
+ 示例:
65
+
66
+ | 指令 | 含义 |
67
+ | --- | --- |
68
+ | `勋章 小女孩` | 最新日期,勋章分数 |
69
+ | `勋章 小女孩 20250917 7日增长率` | 指定日期与模式 |
70
+ | `勋章 小女孩 赛季同比增长率` | 对比上赛季同日 |
71
+
72
+ - 日期:YYYYMMDD(如 20250917),不填默认最新日期
73
+ - 模式:勋章分数(默认)、距平百分比、1/3/7/15/30日增长率、赛季同比增长率
74
+ - 显示 S1/S10/A11/A100/B101/B300/C301/C500 八档勋章分数线
75
+
29
76
  ## 配置
30
77
 
31
78
  | 配置项 | 说明 | 默认值 |
@@ -33,6 +80,7 @@
33
80
  | `dataSource` | 数据源地址,可在两个镜像站点间切换 | `https://website.homeworkkun.top` |
34
81
  | `tryAlternate` | 主数据源失败时自动尝试备用数据源 | `true` |
35
82
  | `defaultPart` | 默认段位(8=巅七,-127=全段位) | `8` |
83
+ | `mapDefaultCamp` | 地图查询默认阵营(1=监管者,2=求生者) | `1` |
36
84
  | `showBadge` | 显示勋章分数据 | `true` |
37
85
  | `badgeDays` | 勋章日增长率天数 | `7` |
38
86
  | `width` | 图片宽度(px) | `1000` |
@@ -43,9 +91,29 @@
43
91
 
44
92
  ## 图片内容
45
93
 
94
+ ### 胜率
95
+
46
96
  - 标题栏:赛季 · 周数 · 段位 · 排序要素
47
97
  - 中央:角色头像、名称、定位/阵营、排序要素数值(含本阵营排名)、要素名
48
98
  - 左侧:六项数据(去平胜率/胜率/平率/负率/使用率/禁用率)及阵营排名、对比上赛季变化值与变化排名、各段位去平胜率(距平均)
49
99
  - 右侧:勋章分数(S1~C500)、赛季同比增长、日增长率、距阵营平均值
50
100
 
101
+ ### 地图
102
+
103
+ - 标题栏:赛季 · 周数 · 段位 · 阵营
104
+ - 中央:地图名、地图编号
105
+ - 数据:胜率/平率/负率/去平胜率,及对比上赛季同周变化
106
+
107
+ ### 模仿者
108
+
109
+ - 标题栏:日期 · 模式 · 阵营
110
+ - 中央:角色名、阵营
111
+ - 数据:胜率/出场率/投票正确率/被杀率,及各模式胜率对比
112
+
113
+ ### 勋章
114
+
115
+ - 标题栏:日期 · 模式 · 阵营
116
+ - 中央:角色名
117
+ - 数据:S1~C500 八档勋章分数线,及所选模式(增长率/距平)计算值
118
+
51
119
  数据均根据第五人格官方小程序数据计算。