koishi-plugin-dwrgbwiki 1.0.0 → 1.2.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 +151 -17
  2. package/lib.js +672 -8
  3. package/package.json +1 -1
  4. package/readme.md +76 -2
package/index.js CHANGED
@@ -5,13 +5,30 @@ const lib = require('./lib')
5
5
 
6
6
  const HELP = lib.buildHelp()
7
7
 
8
+ const MAIN_HELP = [
9
+ '第五人格BWIKI数据查询机器人',
10
+ '',
11
+ '支持指令:',
12
+ ' 胜率 <角色名> [赛季] [周数] [排序要素] [段位]',
13
+ ' 查询角色胜率,例:胜率 小女孩',
14
+ ' 地图 <地图名> [赛季] [周数] [段位] [阵营]',
15
+ ' 查询地图胜率,例:地图 军工厂',
16
+ ' 模仿者 <角色名> [日期] [模式]',
17
+ ' 查询模仿者游戏数据,例:模仿者 侦探',
18
+ ' 勋章 <角色名> [日期] [模式]',
19
+ ' 查询角色勋章分数线,例:勋章 小女孩',
20
+ '',
21
+ '发送“胜率”等单独指令可查看对应完整帮助',
22
+ '数据均根据第五人格官方小程序数据计算',
23
+ ].join('\n')
24
+
8
25
  module.exports = {
9
26
  name: 'dwrgbwiki',
10
27
  description: '第五人格BWIKI编辑组测试用组件,纯AI生成喵',
11
28
 
12
29
  Config: Schema.object({
13
- dataSource: Schema.string()
14
- .description('数据源地址,可在两个镜像站点之间切换:' + lib.KNOWN_SOURCES.join(' / '))
30
+ dataSource: Schema.union(lib.KNOWN_SOURCES)
31
+ .description('数据源地址,可在两个镜像站点之间切换')
15
32
  .default('https://website.homeworkkun.top'),
16
33
  tryAlternate: Schema.boolean()
17
34
  .description('主数据源失败时自动尝试备用数据源')
@@ -19,6 +36,9 @@ module.exports = {
19
36
  defaultPart: Schema.union(['8', '-127'])
20
37
  .description('默认段位(8=巅七,-127=全段位)')
21
38
  .default('8'),
39
+ mapDefaultCamp: Schema.union(['1', '2'])
40
+ .description('地图查询默认阵营(1=监管者,2=求生者)')
41
+ .default('1'),
22
42
  showBadge: Schema.boolean()
23
43
  .description('显示勋章分数据(获取失败时自动隐藏)')
24
44
  .default(true),
@@ -26,8 +46,17 @@ module.exports = {
26
46
  .description('勋章日增长率天数')
27
47
  .default(7),
28
48
  width: Schema.number().min(400).max(4000)
29
- .description('图片宽度(px)')
49
+ .description('图片宽度(px),其他指令未单独设置时使用该值')
30
50
  .default(1000),
51
+ mapWidth: Schema.number().min(400).max(4000)
52
+ .description('地图指令图片宽度(px),0=使用默认宽度')
53
+ .default(0),
54
+ mimicWidth: Schema.number().min(400).max(4000)
55
+ .description('模仿者指令图片宽度(px),0=使用默认宽度')
56
+ .default(0),
57
+ badgeWidth: Schema.number().min(400).max(4000)
58
+ .description('勋章指令图片宽度(px),0=使用默认宽度')
59
+ .default(0),
31
60
  scale: Schema.number().min(1).max(4)
32
61
  .description('截图倍率(越大越清晰)')
33
62
  .default(2),
@@ -37,30 +66,135 @@ module.exports = {
37
66
  }),
38
67
 
39
68
  apply(ctx, config) {
69
+ ctx.command('BWIKI机器人', '显示当前支持的指令列表')
70
+ .action(async ({ session }) => {
71
+ return helpImage(ctx, config, MAIN_HELP)
72
+ })
73
+
40
74
  ctx.command('胜率 <角色名> [条件:text]', '查询角色胜率并生成图片')
41
75
  .usage(HELP)
42
76
  .example('胜率 小女孩')
43
77
  .example('胜率 小女孩 第43赛季 第3周 使用率')
44
78
  .example('胜率 小女孩 43 3 胜率')
45
79
  .action(async ({ session }, name, cond) => {
46
- 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
- }
80
+ if (!name || !name.trim()) return helpImage(ctx, config, HELP)
81
+ return run(ctx, config, lib.collect, lib.buildHtml, name.trim(), cond, 'width')
82
+ })
83
+
84
+ ctx.command('地图 <地图名> [条件:text]', '查询地图胜率并生成图片')
85
+ .usage(MAP_HELP)
86
+ .example('地图 军工厂')
87
+ .example('地图 红教堂 S44 3 求生者')
88
+ .example('地图 月亮河公园 全赛季')
89
+ .action(async ({ session }, name, cond) => {
90
+ if (!name || !name.trim()) return helpImage(ctx, config, MAP_HELP)
91
+ return run(ctx, config, lib.collectMap, lib.buildMapHtml, name.trim(), cond, 'mapWidth')
92
+ })
93
+
94
+ ctx.command('模仿者 <角色名> [条件:text]', '查询模仿者游戏角色数据并生成图片')
95
+ .usage(MIMIC_HELP)
96
+ .example('模仿者 侦探')
97
+ .example('模仿者 药剂师 260531 低级场')
98
+ .action(async ({ session }, name, cond) => {
99
+ if (!name || !name.trim()) return helpImage(ctx, config, MIMIC_HELP)
100
+ return run(ctx, config, lib.collectMimic, lib.buildMimicHtml, name.trim(), cond, 'mimicWidth')
101
+ })
102
+
103
+ ctx.command('勋章 <角色名> [条件:text]', '查询角色勋章分数线并生成图片')
104
+ .usage(BADGE_HELP)
105
+ .example('勋章 小女孩')
106
+ .example('勋章 小女孩 20250917 7日增长率')
107
+ .example('勋章 小女孩 赛季同比增长率')
108
+ .action(async ({ session }, name, cond) => {
109
+ if (!name || !name.trim()) return helpImage(ctx, config, BADGE_HELP)
110
+ return run(ctx, config, lib.collectBadge, lib.buildBadgeHtml, name.trim(), cond, 'badgeWidth')
60
111
  })
61
112
  },
62
113
  }
63
114
 
115
+ async function run(ctx, config, collectFn, buildFn, name, cond, widthKey) {
116
+ if (!ctx.puppeteer) {
117
+ return '未检测到 puppeteer 服务,请先安装并启用 koishi-plugin-puppeteer'
118
+ }
119
+ try {
120
+ const result = await collectFn(config, name, cond)
121
+ if (!result.ok) return errorImage(ctx, config, result.error)
122
+ const width = (config[widthKey] && config[widthKey] > 0) ? config[widthKey] : config.width
123
+ const html = buildFn(result, { ...config, avatarBase: result.base, width })
124
+ const image = await capture(ctx, html, { ...config, width })
125
+ return [h.image(image)]
126
+ } catch (e) {
127
+ ctx.logger('dwrgbwiki').warn(e)
128
+ return errorImage(ctx, config, '查询失败:' + (e && e.message ? e.message : e))
129
+ }
130
+ }
131
+
132
+ async function helpImage(ctx, config, text) {
133
+ if (!ctx.puppeteer) return text
134
+ try {
135
+ const html = lib.buildHelpHtml(text, { ...config, width: config.width })
136
+ const image = await capture(ctx, html, config)
137
+ return [h.image(image)]
138
+ } catch (e) {
139
+ ctx.logger('dwrgbwiki').warn(e)
140
+ return text
141
+ }
142
+ }
143
+
144
+ async function errorImage(ctx, config, text) {
145
+ if (!ctx.puppeteer || !text) return text
146
+ try {
147
+ const html = lib.buildHelpHtml(text, { ...config, width: config.width })
148
+ const image = await capture(ctx, html, config)
149
+ return [h.image(image)]
150
+ } catch (e) {
151
+ ctx.logger('dwrgbwiki').warn(e)
152
+ return text
153
+ }
154
+ }
155
+
156
+ const MAP_HELP = [
157
+ '查询地图胜率并生成图片',
158
+ '',
159
+ '用法:地图 <地图名> [赛季] [周数] [段位] [阵营]',
160
+ '',
161
+ '示例:',
162
+ ' 地图 军工厂',
163
+ ' 地图 红教堂 S44 3 求生者',
164
+ ' 地图 月亮河公园 全赛季 全段位',
165
+ '',
166
+ '阵营:监管者(默认)、求生者;赛季:S44 / 第44赛季;周数:第3周 / 3',
167
+ '段位:巅七(默认)、七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位',
168
+ '赛季、周数不填默认使用最新赛季最新周',
169
+ ].join('\n')
170
+
171
+ const MIMIC_HELP = [
172
+ '查询模仿者游戏角色数据并生成图片',
173
+ '',
174
+ '用法:模仿者 <角色名> [日期] [模式]',
175
+ '',
176
+ '示例:',
177
+ ' 模仿者 侦探',
178
+ ' 模仿者 药剂师 260531 低级场',
179
+ '',
180
+ '日期:YYMMDD(如 260531),不填默认最新日期',
181
+ '模式:低级场、10人高级场、12人高级场、所有模式(默认)',
182
+ ].join('\n')
183
+
184
+ const BADGE_HELP = [
185
+ '查询角色勋章分数线并生成图片',
186
+ '',
187
+ '用法:勋章 <角色名> [日期] [模式]',
188
+ '',
189
+ '示例:',
190
+ ' 勋章 小女孩',
191
+ ' 勋章 小女孩 20250917 7日增长率',
192
+ ' 勋章 小女孩 赛季同比增长率',
193
+ '',
194
+ '日期:YYYYMMDD(如 20250917),不填默认最新日期',
195
+ '模式:勋章分数(默认)、距平百分比、1/3/7/15/30日增长率、赛季同比增长率',
196
+ ].join('\n')
197
+
64
198
  async function capture(ctx, html, config) {
65
199
  const page = await ctx.puppeteer.page()
66
200
  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]
@@ -568,7 +940,8 @@ function buildHtml(data, config) {
568
940
  const zoneLeft = '<div class="stat-block">' + sixRows + '</div>' + changeBlock +
569
941
  '<div class="zone-sep"></div>' +
570
942
  '<div class="stat-block"><div class="comp-title" style="color:var(--accent);margin-bottom:4px">各段位去平胜率(距平均)</div>' +
571
- '<div class="allrank-grid">' + partsItems + '</div></div>'
943
+ '<div class="allrank-grid">' + partsItems + '</div></div>' +
944
+ '<div class="credit">@夜莺小姐档案馆</div>'
572
945
 
573
946
  let zoneRight
574
947
  if (badge) {
@@ -588,9 +961,11 @@ function buildHtml(data, config) {
588
961
  '<div class="card-badge-tiers">' + tierItems + '</div></div>' +
589
962
  growBlock('赛季同比增长 (' + short(badge.dates.prev) + '→' + short(badge.dates.cur) + ')', growItems(badge.season)) +
590
963
  growBlock(badgeDays + '日增长率 (' + short(badge.dates.daily) + '→' + short(badge.dates.cur) + ')', growItems(badge.daily)) +
591
- growBlock('距阵营平均值', growItems(badge.anomaly))
964
+ growBlock('距阵营平均值', growItems(badge.anomaly)) +
965
+ '<div class="cmd-hint">' + esc(buildHelp()) + '</div>'
592
966
  } else {
593
- zoneRight = '<div class="stat-block"><div class="comp-title" style="color:var(--text-secondary)">暂无勋章数据</div></div>'
967
+ zoneRight = '<div class="stat-block"><div class="comp-title" style="color:var(--text-secondary)">暂无勋章数据</div></div>' +
968
+ '<div class="cmd-hint">' + esc(buildHelp()) + '</div>'
594
969
  }
595
970
 
596
971
  const position = card.entry.position
@@ -598,6 +973,14 @@ function buildHtml(data, config) {
598
973
  const avatarUrl = avatar ? avatar.replace(/\/+$/, '') + '/statics/角色头像/' + encodeURIComponent(name) + '.png' : ''
599
974
  const campName = card.camp === 1 ? '监管者' : '求生者'
600
975
 
976
+ const rankRows = buildRankRows(card, metric, data.badgeScores)
977
+ const rankBlock = rankRows.length
978
+ ? '<div class="zone-sep"></div>' +
979
+ '<div class="comp-title" style="color:var(--accent);margin-bottom:4px">本阵营排名(' + metricLabel + ')</div>' +
980
+ '<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>' +
981
+ rankRows.join('') + '</div>'
982
+ : ''
983
+
601
984
  const zoneCenter =
602
985
  '<div class="card-avatar-wrap">' +
603
986
  (avatarUrl
@@ -607,8 +990,9 @@ function buildHtml(data, config) {
607
990
  '</div>' +
608
991
  '<div class="card-char-name">' + esc(name) + '</div>' +
609
992
  (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>'
993
+ '<div class="card-highlight ' + hlCls + '">' + fmtHigh(hl, metric) + '</div>' +
994
+ '<div class="card-highlight-label">' + metricLabel + ' · ' + partName + ' · #' + card.rank + '/' + card.total + '</div>' +
995
+ rankBlock
612
996
 
613
997
  const titleBar =
614
998
  '<span>' + seasonName + '</span><span class="sep">·</span>' +
@@ -642,8 +1026,18 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
642
1026
  .card-highlight.mid { color:var(--yellow); }
643
1027
  .card-highlight.low { color:var(--red); }
644
1028
  .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
1029
  .card-highlight-label { font-size:15px; color:var(--text-secondary); }
1030
+ .rank-table { width:100%; border:1px solid rgba(255,255,255,.08); border-radius:8px; overflow:hidden; }
1031
+ .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; }
1032
+ .rank-row:last-child { border-bottom:none; }
1033
+ .rank-row.self { background:rgba(88,166,255,.12); border-left:3px solid var(--accent); font-weight:700; }
1034
+ .rank-header { background:rgba(255,255,255,.05); font-size:13px; color:var(--text-secondary); font-weight:600; }
1035
+ .rk-pos { color:var(--accent); font-weight:700; font-variant-numeric:tabular-nums; }
1036
+ .rank-row.self .rk-pos { color:var(--yellow); }
1037
+ .rk-name { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1038
+ .rk-val { text-align:right; font-variant-numeric:tabular-nums; font-weight:600; font-size:16px; }
1039
+ .rank-header .rk-pos, .rank-header .rk-val { color:var(--text-secondary); font-weight:600; }
1040
+ .credit { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:14px; opacity:.7; }
647
1041
  .stat-block { display:flex; flex-direction:column; gap:6px; }
648
1042
  .stat-block + .stat-block { margin-top:12px; }
649
1043
  .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 +1065,7 @@ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkM
671
1065
  .card-badge-item.tier-b .b-tier { color:var(--accent); }
672
1066
  .card-badge-item.tier-c .b-tier { color:var(--text-secondary); }
673
1067
  .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1068
+ .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
1069
  </style>
675
1070
  </head>
676
1071
  <body>
@@ -695,6 +1090,236 @@ function fmtHigh(v, metric) {
695
1090
  return (v * 100).toFixed(2) + '%'
696
1091
  }
697
1092
 
1093
+ function buildRankRows(card, metric, badgeScores) {
1094
+ const { rank, total, ranking } = card
1095
+ if (!rank || !total || !ranking || !ranking.length) return []
1096
+ const lo = Math.max(1, rank - 5)
1097
+ const hi = Math.min(total, rank + 5)
1098
+ const list = []
1099
+ if (1 < lo || 1 > hi) list.push(1)
1100
+ for (let i = lo; i <= hi; i++) list.push(i)
1101
+ if (total !== hi && total !== 1) list.push(total)
1102
+ return list.map(r => {
1103
+ const item = ranking[r - 1]
1104
+ if (!item) return ''
1105
+ const v = getMetricVal(item, metric, badgeScores)
1106
+ const self = r === rank
1107
+ return '<div class="rank-row' + (self ? ' self' : '') + '">' +
1108
+ '<span class="rk-pos">#' + r + '</span>' +
1109
+ '<span class="rk-name">' + esc(item.name) + '</span>' +
1110
+ '<span class="rk-val">' + fmtHigh(v, metric) + '</span></div>'
1111
+ })
1112
+ }
1113
+
1114
+ // ===== 地图 HTML =====
1115
+
1116
+ function buildMapHtml(data, config) {
1117
+ const width = config.width || 1000
1118
+ const d = (v, suffix) => v == null || isNaN(v) ? '--' : v.toFixed(2) + suffix
1119
+ const diffHtml = (cur, prev) => {
1120
+ if (cur == null || prev == null) return '<span class="stat-delta">--</span>'
1121
+ const diff = cur - prev
1122
+ const cls = Math.abs(diff) < 0.01 ? '' : (diff > 0 ? 'up' : 'down')
1123
+ return '<span class="stat-delta ' + cls + '">' + (diff > 0 ? '+' : '') + diff.toFixed(2) + '%</span>'
1124
+ }
1125
+
1126
+ const rows = [
1127
+ { label: '胜率', cur: data.win_rate, prev: data.prev ? data.prev.win_rate : null },
1128
+ { label: '平率', cur: data.ping_rate, prev: data.prev ? data.prev.ping_rate : null },
1129
+ { label: '负率', cur: data.fail_rate, prev: data.prev ? data.prev.fail_rate : null },
1130
+ { label: '去平胜率', cur: data.win_rate_nodraw, prev: data.prev ? data.prev.win_rate_nodraw : null },
1131
+ ].map(r =>
1132
+ '<div class="s-row"><span class="s-label">' + r.label + '</span>' +
1133
+ '<span class="s-val">' + d(r.cur, '%') + '</span>' +
1134
+ '<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>'
1135
+ ).join('')
1136
+
1137
+ const prevBlock = data.prev
1138
+ ? '<div class="zone-sep"></div><div class="comp-title" style="color:var(--accent);margin-bottom:4px">对比上赛季(' + data.prevLabel + ')</div>'
1139
+ : ''
1140
+
1141
+ const titleBar =
1142
+ '<span>' + data.seasonName + '</span><span class="sep">·</span>' +
1143
+ '<span>' + data.weekLabel + '</span><span class="sep">·</span>' +
1144
+ '<span>' + data.partName + '</span><span class="sep">·</span>' +
1145
+ '<span>' + data.campName + '</span>'
1146
+
1147
+ const spaceNo = data.spaceNo != null ? '<div class="card-sub">地图编号 ' + esc(String(data.spaceNo)) + '</div>' : ''
1148
+
1149
+ return `<!DOCTYPE html>
1150
+ <html lang="zh-CN">
1151
+ <head>
1152
+ <meta charset="UTF-8">
1153
+ <style>
1154
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1155
+ * { margin:0; padding:0; box-sizing:border-box; }
1156
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1157
+ .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; }
1158
+ .title-bar .sep { color:var(--border); }
1159
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1160
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1161
+ .card-map-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 2px; }
1162
+ .card-sub { font-size:13px; color:var(--text-secondary); margin-bottom:10px; }
1163
+ .stat-block { display:flex; flex-direction:column; gap:6px; }
1164
+ .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; }
1165
+ .s-label { font-size:15px; color:var(--text-secondary); font-weight:600; width:90px; flex-shrink:0; }
1166
+ .s-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; flex:1; text-align:center; }
1167
+ .s-rank { font-size:15px; min-width:110px; text-align:right; }
1168
+ .stat-delta { font-size:14px; font-weight:600; font-variant-numeric:tabular-nums; }
1169
+ .stat-delta.up { color:var(--green); }
1170
+ .stat-delta.down { color:var(--red); }
1171
+ .zone-sep { margin:12px 0; border-top:1px solid rgba(255,255,255,.08); }
1172
+ .comp-title { font-size:15px; color:var(--green); font-weight:700; text-align:center; margin-bottom:8px; letter-spacing:1px; }
1173
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1174
+ </style>
1175
+ </head>
1176
+ <body>
1177
+ <div class="title-bar">${titleBar}</div>
1178
+ <div class="card">
1179
+ <div class="zone-center">
1180
+ <div class="card-map-name">${esc(data.mapName)}</div>
1181
+ ${spaceNo}
1182
+ </div>
1183
+ <div class="stat-block">${rows}</div>
1184
+ ${prevBlock}
1185
+ </div>
1186
+ <div class="foot">数据来源:${esc(data.sourceLabel)} · 数据均根据第五人格官方小程序数据计算</div>
1187
+ </body>
1188
+ </html>`
1189
+ }
1190
+
1191
+ // ===== 模仿者 HTML =====
1192
+
1193
+ function buildMimicHtml(data, config) {
1194
+ const pct = v => v == null || isNaN(v) ? '--' : (v * 100).toFixed(2) + '%'
1195
+ const p = data.primary
1196
+ const rows = [
1197
+ { label: '胜率', val: pct(p.win_rate) },
1198
+ { label: '出场率', val: pct(p.show_rate) },
1199
+ { label: '投票正确率', val: pct(p.vote_correct_rate) },
1200
+ { label: '被杀率', val: pct(p.bekill_rate) },
1201
+ ].map(r =>
1202
+ '<div class="s-row"><span class="s-label">' + r.label + '</span>' +
1203
+ '<span class="s-val">' + r.val + '</span><span class="s-rank"></span></div>'
1204
+ ).join('')
1205
+
1206
+ const otherModes = data.modes.map(m => {
1207
+ const isPrimary = m.key === p.key
1208
+ return '<div class="mode-item' + (isPrimary ? ' active' : '') + '">' +
1209
+ '<span class="mode-name">' + esc(m.label) + '</span>' +
1210
+ '<span class="mode-val">' + pct(m.win_rate) + '</span></div>'
1211
+ }).join('')
1212
+
1213
+ const titleBar =
1214
+ '<span>' + data.dateLabel + '</span><span class="sep">·</span>' +
1215
+ '<span>' + data.modeLabel + '</span><span class="sep">·</span>' +
1216
+ '<span>' + data.campName + '</span>'
1217
+
1218
+ return `<!DOCTYPE html>
1219
+ <html lang="zh-CN">
1220
+ <head>
1221
+ <meta charset="UTF-8">
1222
+ <style>
1223
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1224
+ * { margin:0; padding:0; box-sizing:border-box; }
1225
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1226
+ .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; }
1227
+ .title-bar .sep { color:var(--border); }
1228
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1229
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1230
+ .card-char-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 2px; }
1231
+ .card-camp { font-size:13px; color:var(--accent); margin-bottom:12px; }
1232
+ .stat-block { display:flex; flex-direction:column; gap:6px; }
1233
+ .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; }
1234
+ .s-label { font-size:15px; color:var(--text-secondary); font-weight:600; width:110px; flex-shrink:0; }
1235
+ .s-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; flex:1; text-align:center; }
1236
+ .s-rank { min-width:46px; }
1237
+ .zone-sep { margin:12px 0; border-top:1px solid rgba(255,255,255,.08); }
1238
+ .comp-title { font-size:15px; color:var(--green); font-weight:700; text-align:center; margin-bottom:8px; letter-spacing:1px; }
1239
+ .mode-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:6px; }
1240
+ .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; }
1241
+ .mode-item.active { border-color:var(--accent); }
1242
+ .mode-name { font-size:13px; color:var(--text-secondary); font-weight:600; }
1243
+ .mode-val { font-size:15px; font-weight:700; font-variant-numeric:tabular-nums; }
1244
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1245
+ </style>
1246
+ </head>
1247
+ <body>
1248
+ <div class="title-bar">${titleBar}</div>
1249
+ <div class="card">
1250
+ <div class="zone-center">
1251
+ <div class="card-char-name">${esc(data.name)}</div>
1252
+ <div class="card-camp">${esc(data.campName)}</div>
1253
+ </div>
1254
+ <div class="stat-block">${rows}</div>
1255
+ <div class="zone-sep"></div>
1256
+ <div class="comp-title" style="color:var(--accent);margin-bottom:4px">各模式胜率</div>
1257
+ <div class="mode-grid">${otherModes}</div>
1258
+ </div>
1259
+ <div class="foot">数据来源:${esc(data.sourceLabel)} · 数据文件:${esc(data.file)}</div>
1260
+ </body>
1261
+ </html>`
1262
+ }
1263
+
1264
+ // ===== 勋章 HTML =====
1265
+
1266
+ function buildBadgeHtml(data, config) {
1267
+ const tierItems = data.tiers.map(t => {
1268
+ const deltaHtml = data.mode === 'original'
1269
+ ? ''
1270
+ : '<div class="b-delta ' + (t.delta == null ? '' : (t.delta > 0 ? 'up' : (t.delta < 0 ? 'down' : ''))) + '">' +
1271
+ (t.delta == null ? '--' : (t.delta > 0 ? '+' : '') + t.delta.toFixed(1) + '%') + '</div>'
1272
+ return '<div class="card-badge-item ' + t.cls + '"><span class="b-tier">' + t.label + '</span>' +
1273
+ '<span class="b-val">' + (t.val != null ? t.val : '--') + '</span>' + deltaHtml + '</div>'
1274
+ }).join('')
1275
+
1276
+ const titleBar =
1277
+ '<span>' + data.dateLabel + '</span><span class="sep">·</span>' +
1278
+ '<span>' + data.modeLabel + '</span><span class="sep">·</span>' +
1279
+ '<span>' + data.campName + '</span>'
1280
+
1281
+ const footNote = data.prevDate && data.mode !== 'original'
1282
+ ? '<div class="foot">对比日期:' + data.prevDate + ' · 数据来源:' + esc(data.sourceLabel) + '</div>'
1283
+ : '<div class="foot">数据来源:' + esc(data.sourceLabel) + '</div>'
1284
+
1285
+ return `<!DOCTYPE html>
1286
+ <html lang="zh-CN">
1287
+ <head>
1288
+ <meta charset="UTF-8">
1289
+ <style>
1290
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d2991d; --card-bg:#1c2333; }
1291
+ * { margin:0; padding:0; box-sizing:border-box; }
1292
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1293
+ .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; }
1294
+ .title-bar .sep { color:var(--border); }
1295
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:560px; margin:0 auto; }
1296
+ .zone-center { display:flex; flex-direction:column; align-items:center; }
1297
+ .card-char-name { font-size:28px; font-weight:700; text-align:center; margin:4px 0 8px; }
1298
+ .card-badge-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }
1299
+ .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; }
1300
+ .b-tier { font-size:13px; color:var(--text-secondary); font-weight:700; }
1301
+ .b-val { font-size:18px; font-weight:700; font-variant-numeric:tabular-nums; }
1302
+ .b-delta { font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; }
1303
+ .b-delta.up { color:var(--green); }
1304
+ .b-delta.down { color:var(--red); }
1305
+ .card-badge-item.tier-s .b-tier { color:var(--yellow); }
1306
+ .card-badge-item.tier-a .b-tier { color:var(--green); }
1307
+ .card-badge-item.tier-b .b-tier { color:var(--accent); }
1308
+ .card-badge-item.tier-c .b-tier { color:var(--text-secondary); }
1309
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1310
+ </style>
1311
+ </head>
1312
+ <body>
1313
+ <div class="title-bar">${titleBar}</div>
1314
+ <div class="card">
1315
+ <div class="zone-center"><div class="card-char-name">${esc(data.name)}</div></div>
1316
+ <div class="card-badge-grid">${tierItems}</div>
1317
+ </div>
1318
+ ${footNote}
1319
+ </body>
1320
+ </html>`
1321
+ }
1322
+
698
1323
  function buildHelp() {
699
1324
  return [
700
1325
  '查询角色胜率并生成图片',
@@ -714,16 +1339,55 @@ function buildHelp() {
714
1339
  ].join('\n')
715
1340
  }
716
1341
 
1342
+ function buildHelpHtml(text, config) {
1343
+ const width = config.width || 1000
1344
+ return `<!DOCTYPE html>
1345
+ <html lang="zh-CN">
1346
+ <head>
1347
+ <meta charset="UTF-8">
1348
+ <style>
1349
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#c9d1d9; --text-secondary:#8b949e; --accent:#58a6ff; --card-bg:#1c2333; }
1350
+ * { margin:0; padding:0; box-sizing:border-box; }
1351
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:16px; }
1352
+ .card { background:var(--card-bg); border:1px solid var(--border); border-radius:18px; padding:22px 26px; max-width:${Math.min(width, 720)}px; margin:0 auto; }
1353
+ .help-title { font-size:22px; font-weight:700; color:var(--accent); text-align:center; margin-bottom:14px; }
1354
+ .help-text { font-size:15px; line-height:1.8; white-space:pre-wrap; word-break:break-all; }
1355
+ .foot { text-align:center; color:var(--text-secondary); font-size:12px; margin-top:10px; opacity:.7; }
1356
+ </style>
1357
+ </head>
1358
+ <body>
1359
+ <div class="card">
1360
+ <div class="help-title">指令帮助</div>
1361
+ <div class="help-text">${esc(text)}</div>
1362
+ </div>
1363
+ <div class="foot">数据均根据第五人格官方小程序数据计算</div>
1364
+ </body>
1365
+ </html>`
1366
+ }
1367
+
717
1368
  module.exports = {
718
1369
  KNOWN_SOURCES,
719
1370
  METRICS,
720
1371
  BADGE_TIERS,
721
1372
  PARTS,
722
1373
  PART_ORDER,
1374
+ MAP_CAMPS,
1375
+ MIMIC_MODES,
1376
+ BADGE_MODES,
723
1377
  parseArgs,
1378
+ parseMapArgs,
1379
+ parseMimicArgs,
1380
+ parseBadgeArgs,
724
1381
  resolveSource,
725
1382
  collect,
1383
+ collectMap,
1384
+ collectMimic,
1385
+ collectBadge,
726
1386
  buildHtml,
1387
+ buildMapHtml,
1388
+ buildMimicHtml,
1389
+ buildBadgeHtml,
727
1390
  buildHelp,
1391
+ buildHelpHtml,
728
1392
  findSimilar,
729
1393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "第五人格BWIKI编辑组测试用组件,纯AI生成喵",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -2,16 +2,24 @@
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
 
9
9
  ## 用法
10
10
 
11
11
  ```
12
+ BWIKI机器人
12
13
  胜率 <角色名> [赛季] [周数] [排序要素] [段位]
14
+ 地图 <地图名> [赛季] [周数] [段位] [阵营]
15
+ 模仿者 <角色名> [日期] [模式]
16
+ 勋章 <角色名> [日期] [模式]
13
17
  ```
14
18
 
19
+ 发送 `BWIKI机器人` 可查看当前支持的指令列表图片;发送 `胜率` 等单独指令可查看对应完整帮助。
20
+
21
+ ### 胜率(角色胜率)
22
+
15
23
  示例:
16
24
 
17
25
  | 指令 | 含义 |
@@ -26,6 +34,48 @@
26
34
  - 排序要素默认 `去平胜率`,可选:胜率、平率、负率、使用率、禁用率、S1/S10/A11/A100/B101/B300/C301/C500 勋章分
27
35
  - 段位默认 `巅七`,可选:七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位
28
36
 
37
+ ### 地图(地图胜率)
38
+
39
+ 示例:
40
+
41
+ | 指令 | 含义 |
42
+ | --- | --- |
43
+ | `地图 军工厂` | 最新赛季最新周,监管者视角 |
44
+ | `地图 红教堂 S44 3 求生者` | 指定赛季、周数与阵营 |
45
+ | `地图 月亮河公园 全赛季 全段位` | 赛季累计与全段位 |
46
+ | `地图 湖景` | 支持模糊匹配地图名 |
47
+
48
+ - 阵营默认 `监管者`,可选:求生者;赛季:S44 / 第44赛季;周数:第3周 / 3
49
+ - 段位默认 `巅七`,可选:七阶、六阶、五阶、四阶、三阶、二阶、一阶、全段位
50
+ - 显示胜率/平率/负率/去平胜率,以及对比上赛季同周的变化
51
+
52
+ ### 模仿者(模仿者游戏数据)
53
+
54
+ 示例:
55
+
56
+ | 指令 | 含义 |
57
+ | --- | --- |
58
+ | `模仿者 侦探` | 最新日期,所有模式 |
59
+ | `模仿者 药剂师 260531 12人高级场` | 指定日期与模式 |
60
+
61
+ - 日期:YYMMDD(如 260531),不填默认最新日期
62
+ - 模式:低级场、10人高级场、12人高级场、所有模式(默认)
63
+ - 显示胜率/出场率/投票正确率/被杀率,以及各模式胜率对比;角色阵营分为侦探团/模仿者/中立
64
+
65
+ ### 勋章(角色勋章分数线)
66
+
67
+ 示例:
68
+
69
+ | 指令 | 含义 |
70
+ | --- | --- |
71
+ | `勋章 小女孩` | 最新日期,勋章分数 |
72
+ | `勋章 小女孩 20250917 7日增长率` | 指定日期与模式 |
73
+ | `勋章 小女孩 赛季同比增长率` | 对比上赛季同日 |
74
+
75
+ - 日期:YYYYMMDD(如 20250917),不填默认最新日期
76
+ - 模式:勋章分数(默认)、距平百分比、1/3/7/15/30日增长率、赛季同比增长率
77
+ - 显示 S1/S10/A11/A100/B101/B300/C301/C500 八档勋章分数线
78
+
29
79
  ## 配置
30
80
 
31
81
  | 配置项 | 说明 | 默认值 |
@@ -33,9 +83,13 @@
33
83
  | `dataSource` | 数据源地址,可在两个镜像站点间切换 | `https://website.homeworkkun.top` |
34
84
  | `tryAlternate` | 主数据源失败时自动尝试备用数据源 | `true` |
35
85
  | `defaultPart` | 默认段位(8=巅七,-127=全段位) | `8` |
86
+ | `mapDefaultCamp` | 地图查询默认阵营(1=监管者,2=求生者) | `1` |
36
87
  | `showBadge` | 显示勋章分数据 | `true` |
37
88
  | `badgeDays` | 勋章日增长率天数 | `7` |
38
- | `width` | 图片宽度(px) | `1000` |
89
+ | `width` | 图片宽度(px),其他指令未单独设置时使用该值 | `1000` |
90
+ | `mapWidth` | 地图指令图片宽度(px),0=使用默认宽度 | `0` |
91
+ | `mimicWidth` | 模仿者指令图片宽度(px),0=使用默认宽度 | `0` |
92
+ | `badgeWidth` | 勋章指令图片宽度(px),0=使用默认宽度 | `0` |
39
93
  | `scale` | 截图倍率 | `2` |
40
94
  | `timeout` | 网络请求超时(毫秒) | `15000` |
41
95
 
@@ -43,9 +97,29 @@
43
97
 
44
98
  ## 图片内容
45
99
 
100
+ ### 胜率
101
+
46
102
  - 标题栏:赛季 · 周数 · 段位 · 排序要素
47
103
  - 中央:角色头像、名称、定位/阵营、排序要素数值(含本阵营排名)、要素名
48
104
  - 左侧:六项数据(去平胜率/胜率/平率/负率/使用率/禁用率)及阵营排名、对比上赛季变化值与变化排名、各段位去平胜率(距平均)
49
105
  - 右侧:勋章分数(S1~C500)、赛季同比增长、日增长率、距阵营平均值
50
106
 
107
+ ### 地图
108
+
109
+ - 标题栏:赛季 · 周数 · 段位 · 阵营
110
+ - 中央:地图名、地图编号
111
+ - 数据:胜率/平率/负率/去平胜率,及对比上赛季同周变化
112
+
113
+ ### 模仿者
114
+
115
+ - 标题栏:日期 · 模式 · 阵营
116
+ - 中央:角色名、阵营
117
+ - 数据:胜率/出场率/投票正确率/被杀率,及各模式胜率对比
118
+
119
+ ### 勋章
120
+
121
+ - 标题栏:日期 · 模式 · 阵营
122
+ - 中央:角色名
123
+ - 数据:S1~C500 八档勋章分数线,及所选模式(增长率/距平)计算值
124
+
51
125
  数据均根据第五人格官方小程序数据计算。