koishi-plugin-dwrgbwiki 1.2.0 → 1.3.1

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 (5) hide show
  1. package/gacha.js +1503 -0
  2. package/index.js +207 -12
  3. package/lib.js +79 -19
  4. package/package.json +4 -3
  5. package/readme.md +1 -1
package/gacha.js ADDED
@@ -0,0 +1,1503 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // ===== 常量 =====
7
+
8
+ const RESOURCE_TYPES = {
9
+ inspiration: '灵感',
10
+ clue: '线索',
11
+ fragments: '碎片',
12
+ rank_treasure: '排位珍宝',
13
+ huoqi: '火漆',
14
+ nightingale: '夜莺祝福',
15
+ ink: '墨迹',
16
+ manuscript: '未竟手稿',
17
+ }
18
+
19
+ const CACHE_TTL = 15 * 60 * 1000
20
+ const cache = new Map()
21
+
22
+ // ===== 内部工具 =====
23
+
24
+ async function fetchText(fullUrl, timeout) {
25
+ const hit = cache.get(fullUrl)
26
+ if (hit && Date.now() - hit.ts < hit.ttl) return hit.text
27
+ const ctrl = new AbortController()
28
+ const timer = setTimeout(() => ctrl.abort(), timeout || 15000)
29
+ try {
30
+ const res = await fetch(fullUrl, { signal: ctrl.signal })
31
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + fullUrl)
32
+ const text = await res.text()
33
+ cache.set(fullUrl, { text, ts: Date.now(), ttl: CACHE_TTL })
34
+ return text
35
+ } finally {
36
+ clearTimeout(timer)
37
+ }
38
+ }
39
+
40
+ async function fetchJson(fullUrl, timeout) {
41
+ return JSON.parse(await fetchText(fullUrl, timeout))
42
+ }
43
+
44
+ function esc(s) {
45
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
46
+ }
47
+
48
+ // ===== 数据源解析(与 lib.js 相同的双源逻辑) =====
49
+
50
+ const KNOWN_SOURCES = [
51
+ 'https://website.homeworkkun.top',
52
+ 'https://web.homeworkkun.top',
53
+ ]
54
+
55
+ async function resolveSource(config) {
56
+ const primary = (config && config.dataSource || KNOWN_SOURCES[0]).replace(/\/+$/, '')
57
+ const candidates = [primary]
58
+ KNOWN_SOURCES.forEach(s => { const x = s.replace(/\/+$/, ''); if (!candidates.includes(x)) candidates.push(x) })
59
+ let lastErr = null
60
+ for (const base of candidates) {
61
+ try {
62
+ const text = await fetchText(url(base, '/抽卡/more/list.json'), config.timeout)
63
+ const j = JSON.parse(text)
64
+ if (Array.isArray(j)) return { base, sourceLabel: base.replace(/^https?:\/\//, '') }
65
+ } catch (e) { lastErr = e }
66
+ }
67
+ throw lastErr || new Error('所有数据源均不可用')
68
+ }
69
+
70
+ function url(base, path2) {
71
+ return base.replace(/\/+$/, '') + '/' + String(path2).replace(/^\/+/, '')
72
+ }
73
+
74
+ // ===== 资源持久化(优先 Koishi 数据库,回退文件) =====
75
+
76
+ class Store {
77
+ constructor(ctx, file) {
78
+ this.ctx = ctx || null
79
+ this.file = file || ''
80
+ this.cache = new Map() // userId -> 用户数据对象(内存缓存)
81
+ }
82
+
83
+ async getUser(userId) {
84
+ const key = String(userId)
85
+ if (this.cache.has(key)) return this.cache.get(key)
86
+ let user = null
87
+ if (this.ctx && this.ctx.database) {
88
+ try {
89
+ const rows = await this.ctx.database.get('gacha_data', { id: key })
90
+ if (rows && rows.length && rows[0].data) user = JSON.parse(rows[0].data)
91
+ } catch (e) {
92
+ console.error('[gacha] 数据库读取失败,尝试文件:', e.message)
93
+ }
94
+ }
95
+ if (!user && this.file) {
96
+ try {
97
+ if (fs.existsSync(this.file)) {
98
+ const all = JSON.parse(fs.readFileSync(this.file, 'utf8'))
99
+ user = all && all.users ? all.users[key] : null
100
+ }
101
+ } catch (e) {
102
+ console.error('[gacha] 文件读取失败:', e.message)
103
+ }
104
+ }
105
+ if (!user || typeof user !== 'object') user = { resources: {}, owned: {}, pools: {}, daily: {} }
106
+ this.cache.set(key, user)
107
+ return user
108
+ }
109
+
110
+ async save(userId) {
111
+ const key = String(userId)
112
+ const user = this.cache.get(key)
113
+ if (!user) return
114
+ if (this.ctx && this.ctx.database) {
115
+ try {
116
+ await this.ctx.database.upsert('gacha_data', [{ id: key, data: JSON.stringify(user) }])
117
+ return
118
+ } catch (e) {
119
+ console.error('[gacha] 数据库写入失败,回退文件:', e.message)
120
+ }
121
+ }
122
+ if (this.file) {
123
+ try {
124
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
125
+ let all = { users: {} }
126
+ if (fs.existsSync(this.file)) {
127
+ try { all = JSON.parse(fs.readFileSync(this.file, 'utf8')) } catch (e) { all = { users: {} } }
128
+ }
129
+ if (!all.users) all.users = {}
130
+ all.users[key] = user
131
+ const tmp = this.file + '.tmp'
132
+ fs.writeFileSync(tmp, JSON.stringify(all))
133
+ fs.renameSync(tmp, this.file)
134
+ } catch (e) {
135
+ console.error('[gacha] 文件保存失败:', e.message)
136
+ }
137
+ }
138
+ }
139
+
140
+ async resetUser(userId) {
141
+ const key = String(userId)
142
+ const user = { resources: {}, owned: {}, pools: {}, daily: {} }
143
+ this.cache.set(key, user)
144
+ if (this.ctx && this.ctx.database) {
145
+ try {
146
+ await this.ctx.database.upsert('gacha_data', [{ id: key, data: JSON.stringify(user) }])
147
+ return
148
+ } catch (e) { /* 忽略 */ }
149
+ }
150
+ if (this.file) await this.save(key)
151
+ }
152
+ }
153
+
154
+ // ===== 卡池数据加载 =====
155
+
156
+ // 卡池类型定义
157
+ // type: 数据类型标识; listPath: list.json 路径; poolPath: pool.json 路径模板
158
+ const POOL_TYPES = {
159
+ jinghua: { label: '精华', listPath: '/抽卡/more/list.json', poolPath: '/抽卡/pools/{id}/pool.json', probPath: '/抽卡/pools/{id}/possibility.json', listFilter: v => v.version === 'V1.html' },
160
+ paivei: { label: '排位珍宝', listPath: '/抽卡/more/list.json', poolPath: '/抽卡/pools/{id}/pool.json', probPath: '/抽卡/pools/{id}/possibility.json', listFilter: v => v.version === 'V2.html' },
161
+ xingyun: { label: '幸运之骰', listPath: '/抽卡/XYZT/list.json', poolPath: '/抽卡/XYZT/{id}/pool.json', probPath: '/抽卡/XYZT/{id}/possibility.json' },
162
+ jianying: { label: '鉴影寻宝', listPath: '/抽卡/JYXB/list.json', poolPath: '/抽卡/JYXB/{id}/pool.json', probPath: '/抽卡/JYXB/{id}/possibility.json' },
163
+ huanxiang: { label: '幻象迷途', listPath: '/抽卡/HXMT/pool.json', poolPath: '/抽卡/HXMT/pool.json', probPath: '/抽卡/HXMT/possibility.json' },
164
+ jiaye: { label: '加页手记', listPath: '/抽卡/jiayeshouji/list.json', poolPath: '/抽卡/jiayeshouji/{id}/pool.json' },
165
+ }
166
+
167
+ const POOL_TYPE_ALIAS = {
168
+ 精华: 'jinghua', 排位: 'paivei', 排位珍宝: 'paivei', 幸运: 'xingyun', 幸运之骰: 'xingyun',
169
+ 鉴影: 'jianying', 鉴影寻宝: 'jianying', 幻象: 'huanxiang', 幻象迷途: 'huanxiang', 加页: 'jiaye', 加页手记: 'jiaye',
170
+ }
171
+
172
+ async function loadPoolList(base, type, timeout) {
173
+ const def = POOL_TYPES[type]
174
+ const j = await fetchJson(url(base, def.listPath), timeout)
175
+ if (Array.isArray(j)) return j
176
+ return [j]
177
+ }
178
+
179
+ async function loadPoolConfig(base, type, id, timeout) {
180
+ const def = POOL_TYPES[type]
181
+ const j = await fetchJson(url(base, def.poolPath.replace('{id}', id)), timeout)
182
+ return j
183
+ }
184
+
185
+ async function loadProbConfig(base, type, id, timeout) {
186
+ const def = POOL_TYPES[type]
187
+ if (!def.probPath) return null
188
+ try {
189
+ return await fetchJson(url(base, def.probPath.replace('{id}', id)), timeout)
190
+ } catch (e) {
191
+ return null
192
+ }
193
+ }
194
+
195
+ function findPoolId(list, query) {
196
+ const q = String(query || '').trim().toLowerCase()
197
+ if (!q) return null
198
+ const exact = list.find(x => String(x.id).toLowerCase() === q)
199
+ if (exact) return exact.id
200
+ const num = q.replace(/^第/, '').replace(/期$/, '').replace(/季$/, '')
201
+ const byNum = list.find(x => String(x.id).toLowerCase() === num.toLowerCase() || String(x.id).toLowerCase().replace(/^[a-z]+/i, '') === num.toLowerCase())
202
+ if (byNum) return byNum.id
203
+ const byName = list.find(x => String(x.name || '').includes(q) || q.includes(String(x.name || '').toLowerCase()))
204
+ if (byName) return byName.id
205
+ return null
206
+ }
207
+
208
+ // ===== 抽卡核心 =====
209
+
210
+ // 通用:物品唯一标识
211
+ function itemKey(item) {
212
+ return [item.name, item.type, item.rarity, item.img || ''].join('|')
213
+ }
214
+
215
+ // 记录已抽到物品(存储名称/稀有度/数量/图片,兼容旧版 true 数据)
216
+ function markOwned(user, item) {
217
+ const key = itemKey(item)
218
+ const prev = user.owned[key]
219
+ user.owned[key] = {
220
+ name: item.name,
221
+ type: item.type,
222
+ rarity: item.rarity,
223
+ img: item.img || item.icon || '',
224
+ count: (prev && typeof prev === 'object' ? prev.count || 0 : 0) + 1,
225
+ }
226
+ return key
227
+ }
228
+
229
+ // 通用状态字段
230
+ function defaultPoolState() {
231
+ return {
232
+ total: 0,
233
+ pityGold: 0, pityPurple: 0, pityBlue: 0, pityFrame: 0,
234
+ lastAPullName: null, consecutiveAPullCount: 0,
235
+ discountRate: 1, discountName: null,
236
+ itemCounts: {},
237
+ pityCounts: {},
238
+ }
239
+ }
240
+
241
+ // ---------- V1 精华 ----------
242
+
243
+ function v1Rarity(prob, state, diffA) {
244
+ const p = prob.probabilities || prob
245
+ if (diffA !== 3) {
246
+ const goldChance = p.S[state.pityGold - 1] != null ? p.S[state.pityGold - 1] : p.S[p.S.length - 1]
247
+ if (Math.random() < goldChance) return 'S'
248
+ }
249
+ const purpleChance = p.A[state.pityPurple - 1] != null ? p.A[state.pityPurple - 1] : p.A[p.A.length - 1]
250
+ if (Math.random() < purpleChance) return 'A'
251
+ const blueChance = p.B[state.pityBlue - 1] != null ? p.B[state.pityBlue - 1] : p.B[p.B.length - 1]
252
+ if (Math.random() < blueChance) return 'B'
253
+ const totalCD = (p.C_chance || 0) + (p.D_chance || 0)
254
+ if (totalCD > 0 && Math.random() * totalCD < (p.C_chance || 0)) return 'C'
255
+ return 'D'
256
+ }
257
+
258
+ function selectAItem(pool, state, user, diffA) {
259
+ const aPool = pool.items.A || []
260
+ if (!aPool.length) return { name: '未知A级物品', type: '未知', rarity: 'A' }
261
+ if (diffA === 0 || diffA === 3) {
262
+ const unowned = aPool.filter(item => !user.owned[itemKey({ ...item, rarity: 'A' })])
263
+ const pool2 = unowned.length > 0 ? unowned : aPool
264
+ return pool2[Math.floor(Math.random() * pool2.length)]
265
+ }
266
+ if (diffA === 1) {
267
+ if (state.consecutiveAPullCount >= 2 && state.lastAPullName) {
268
+ const diff = aPool.filter(item => item.name !== state.lastAPullName)
269
+ const pool2 = diff.length > 0 ? diff : aPool
270
+ return pool2[Math.floor(Math.random() * pool2.length)]
271
+ }
272
+ }
273
+ return aPool[Math.floor(Math.random() * aPool.length)]
274
+ }
275
+
276
+ function pickItem(pool, r, user, state, diffA) {
277
+ let template
278
+ if (r === 'A') {
279
+ template = selectAItem(pool, state, user, diffA)
280
+ if (diffA === 1) {
281
+ if (template.name === state.lastAPullName) state.consecutiveAPullCount++
282
+ else { state.lastAPullName = template.name; state.consecutiveAPullCount = 1 }
283
+ }
284
+ } else {
285
+ const items = pool.items[r] || []
286
+ template = items.length > 0 ? items[Math.floor(Math.random() * items.length)] : { name: '未知' + r + '级物品', type: '未知', rarity: r }
287
+ }
288
+ return { ...template, rarity: r }
289
+ }
290
+
291
+ function v1Draw(pool, prob, state, user) {
292
+ const diffA = pool.diff_A != null ? pool.diff_A : 0
293
+ const pity = pool.pitySettings || {}
294
+ const gold = pity.gold || 0, purple = pity.purple || 0, blue = pity.blue || 0
295
+
296
+ state.total++
297
+ if (diffA !== 3) state.pityGold++
298
+ state.pityPurple++
299
+ state.pityBlue++
300
+
301
+ let r = null
302
+ if (diffA !== 3 && state.pityGold >= gold) r = 'S'
303
+ else if (state.pityPurple >= purple) r = 'A'
304
+ else if (state.pityBlue >= blue) r = 'B'
305
+ if (!r) r = v1Rarity(prob, state, diffA)
306
+
307
+ const item = pickItem(pool, r, user, state, diffA)
308
+ if (r === 'S' && diffA !== 3) state.pityGold = 0
309
+ else if (r === 'A') state.pityPurple = 0
310
+ else if (r === 'B') state.pityBlue = 0
311
+
312
+ const key = markOwned(user, item)
313
+ const isRepeat = !!user.owned[key]
314
+ return { item, isRepeat, fragments: isRepeat ? (item.repeat || 0) : 0 }
315
+ }
316
+
317
+ // ---------- V2 排位珍宝(含 F 级) ----------
318
+
319
+ function v2Rarity(prob, state, pool) {
320
+ const p = prob.probabilities || prob
321
+ const goldChance = p.S[state.pityGold - 1] != null ? p.S[state.pityGold - 1] : p.S[p.S.length - 1]
322
+ if (Math.random() < goldChance) return 'S'
323
+ const purpleChance = p.A[state.pityPurple - 1] != null ? p.A[state.pityPurple - 1] : p.A[p.A.length - 1]
324
+ if (Math.random() < purpleChance) return 'A'
325
+ const blueChance = p.B[state.pityBlue - 1] != null ? p.B[state.pityBlue - 1] : p.B[p.B.length - 1]
326
+ if (Math.random() < blueChance) return 'B'
327
+ if (p.F && p.F.length > 0) {
328
+ const frameChance = p.F[state.pityFrame - 1] != null ? p.F[state.pityFrame - 1] : (p.F_chance || 0)
329
+ if (Math.random() < frameChance) return 'F'
330
+ }
331
+ const totalCD = (p.C_chance || 0) + (p.D_chance || 0)
332
+ if (totalCD > 0 && Math.random() * totalCD < (p.C_chance || 0)) return 'C'
333
+ return 'D'
334
+ }
335
+
336
+ function v2Draw(pool, prob, state, user) {
337
+ const pity = pool.pitySettings || {}
338
+ const gold = pity.gold || 0, purple = pity.purple || 0, blue = pity.blue || 0
339
+ const frame = pool.frameSettings || {}
340
+ const frameThreshold = frame.pityThreshold || 0
341
+ const diffA = pool.diff_A != null ? pool.diff_A : 0
342
+
343
+ state.total++
344
+ state.pityGold++
345
+ state.pityPurple++
346
+ state.pityBlue++
347
+ state.pityFrame++
348
+
349
+ let r = v2Rarity(prob, state, pool)
350
+ if (state.pityGold >= gold) r = 'S'
351
+ else if (state.pityPurple >= purple) r = r !== 'S' ? 'A' : r
352
+ else if (state.pityBlue >= blue) r = (r !== 'S' && r !== 'A') ? 'B' : r
353
+ else if (frame.enabled && state.pityFrame >= frameThreshold && r !== 'S' && r !== 'A' && r !== 'B') r = 'F'
354
+
355
+ let item
356
+ if (r === 'F') {
357
+ item = { ...(frame.item || {}), rarity: 'F' }
358
+ } else {
359
+ item = pickItem(pool, r, user, state, diffA)
360
+ }
361
+
362
+ if (r === 'S') state.pityGold = 0
363
+ else if (r === 'A') state.pityPurple = 0
364
+ else if (r === 'B') state.pityBlue = 0
365
+ else if (r === 'F') state.pityFrame = 0
366
+
367
+ const key = markOwned(user, item)
368
+ const isRepeat = !!user.owned[key]
369
+ return { item, isRepeat, fragments: isRepeat ? (item.repeat || 0) : 0 }
370
+ }
371
+
372
+ // ---------- XYZT 幸运之骰 ----------
373
+
374
+ function xingyunDraw(pool, prob, state, user) {
375
+ state.total++
376
+ const completeDrawLimit = pool.completeDrawLimit || 0
377
+ const tiers = (prob && prob.tiers) || []
378
+
379
+ // 达到强制完成限制时,优先从未收集物品中抽取
380
+ if (completeDrawLimit > 0 && state.total >= completeDrawLimit) {
381
+ const allNames = Object.keys(pool.items || {})
382
+ const uncollected = allNames.filter(name => {
383
+ const it = pool.items[name]
384
+ return !user.owned[itemKey({ ...it, name, rarity: it.rarity })]
385
+ })
386
+ if (uncollected.length > 0) {
387
+ const name = uncollected[Math.floor(Math.random() * uncollected.length)]
388
+ const it = pool.items[name]
389
+ return { item: { ...it, name, rarity: it.rarity }, isRepeat: false, fragments: 0, guaranteed: true }
390
+ }
391
+ }
392
+
393
+ if (tiers.length > 0) {
394
+ const rand = Math.random()
395
+ let cumulative = 0
396
+ let tier = null
397
+ for (const t of tiers) {
398
+ cumulative += t.probability || 0
399
+ if (rand < cumulative) { tier = t; break }
400
+ }
401
+ if (tier) {
402
+ const totalWeight = (tier.items || []).reduce((s, it) => s + (it.weight || 1), 0)
403
+ let r = Math.random() * totalWeight
404
+ let picked = null
405
+ for (const ti of tier.items || []) {
406
+ r -= (ti.weight || 1)
407
+ if (r < 0) { picked = ti; break }
408
+ }
409
+ const fallback = (tier.items || [])[0]
410
+ const name = (picked || fallback || {}).name
411
+ if (name && pool.items[name]) {
412
+ const it = pool.items[name]
413
+ const final = { ...it, name, rarity: it.rarity }
414
+ const key = markOwned(user, final)
415
+ const isRepeat = !!user.owned[key]
416
+ return { item: final, isRepeat, fragments: isRepeat ? (it.repeat || 0) : 0 }
417
+ }
418
+ }
419
+ }
420
+
421
+ // 后备:全物品随机
422
+ const names = Object.keys(pool.items || {})
423
+ const name = names[Math.floor(Math.random() * names.length)]
424
+ const it = pool.items[name] || { type: '未知', rarity: 'D' }
425
+ const final = { ...it, name, rarity: it.rarity }
426
+ const key = markOwned(user, final)
427
+ const isRepeat = !!user.owned[key]
428
+ return { item: final, isRepeat, fragments: isRepeat ? (it.repeat || 0) : 0 }
429
+ }
430
+
431
+ // ---------- JYXB 鉴影寻宝 ----------
432
+
433
+ function jyxbRarity(prob, state, isOld) {
434
+ const p = (prob && prob.probabilities) || {}
435
+ const rand = Math.random()
436
+ if (isOld) {
437
+ if (rand < (p.A ? p.A[0] : 0.01)) return 'A'
438
+ if (rand < ((p.A ? p.A[0] : 0.01) + (p.B ? p.B[0] : 0.05))) return 'B'
439
+ if (rand < ((p.A ? p.A[0] : 0.01) + (p.B ? p.B[0] : 0.05) + (p.C ? p.C[0] : 0.3))) return 'C'
440
+ return 'D'
441
+ }
442
+ if (rand < (p.A ? p.A[0] : 0.05)) return 'A'
443
+ if (rand < ((p.A ? p.A[0] : 0.05) + (p.B ? p.B[0] : 0.2))) return 'B'
444
+ return 'C'
445
+ }
446
+
447
+ function jyxbDraw(pool, prob, state, user, isOld) {
448
+ state.total++
449
+ state.pityA++
450
+ let r = jyxbRarity(prob, state, isOld)
451
+ if (state.pityA >= (pool.pitySettings?.gold || 80)) { r = 'A'; state.pityA = 0 }
452
+ const items = pool.items[r] || []
453
+ const item = items.length > 0 ? items[Math.floor(Math.random() * items.length)] : { name: '未知' + r + '级', type: '奖励', rarity: r }
454
+ const final = { ...item, rarity: r }
455
+ const key = markOwned(user, final)
456
+ const isRepeat = !!user.owned[key]
457
+
458
+ let fragments = 0, clue = 0
459
+ const match = String(item.name || '').match(/\*(\d+)/)
460
+ const amount = match ? parseInt(match[1]) : 0
461
+ if (r === 'A') {
462
+ if (isRepeat) fragments = 200
463
+ } else if (r === 'B') {
464
+ if (isOld) fragments = amount || 10
465
+ else fragments = amount || 10
466
+ } else if (r === 'C') {
467
+ if (isOld) fragments = amount || 10
468
+ else clue = amount || 10
469
+ } else if (r === 'D') {
470
+ clue = amount || 10
471
+ }
472
+ return { item: final, isRepeat, fragments, clue }
473
+ }
474
+
475
+ // ---------- HXMT 幻象迷途 ----------
476
+
477
+ function hxmtRarity(prob) {
478
+ const p = prob.probabilities || prob
479
+ const rand = Math.random()
480
+ let cumulative = 0
481
+ const order = ['senior', 'intermediate', 'junior', 'basic', 'discount']
482
+ for (const tier of order) {
483
+ cumulative += p[tier] || 0
484
+ if (rand < cumulative) return tier
485
+ }
486
+ return 'discount'
487
+ }
488
+
489
+ function hxmtDraw(pool, prob, state, user) {
490
+ state.total++
491
+ const r = hxmtRarity(prob)
492
+ const items = pool.items[r] || []
493
+ const item = items.length > 0 ? items[Math.floor(Math.random() * items.length)] : { name: '未知奖励', type: '奖励', rarity: r }
494
+ const final = { ...item, rarity: r }
495
+ let fragments = 0, clue = 0, nightingale = 0
496
+
497
+ if (r === 'discount') {
498
+ // 折扣不参与重复
499
+ } else {
500
+ const key = markOwned(user, final)
501
+ const isRepeat = !!user.owned[key]
502
+ final.isRepeat = isRepeat
503
+ if (isRepeat) {
504
+ const reward = (pool.repeatRewards && pool.repeatRewards[r]) || 0
505
+ nightingale = reward
506
+ final.repeatBonus = reward
507
+ }
508
+ if (item.type === '线索') {
509
+ const m = String(item.name || '').match(/(\d+)/)
510
+ if (m) clue = parseInt(m[1])
511
+ } else if (item.type === '碎片') {
512
+ const m = String(item.name || '').match(/(\d+)/)
513
+ if (m) fragments = parseInt(m[1])
514
+ }
515
+ }
516
+ return { item: final, fragments, clue, nightingale }
517
+ }
518
+
519
+ // ---------- jiayeshouji 加页手记 ----------
520
+
521
+ function jiayeDraw(pool, prob, state, user) {
522
+ state.total++
523
+ const allItems = []
524
+ for (const cat of Object.keys(pool.items || {})) {
525
+ for (const it of pool.items[cat] || []) {
526
+ allItems.push({ ...it, cat })
527
+ }
528
+ }
529
+ if (!allItems.length) return { item: { name: '未知', type: '未知', rarity: 'D' }, isRepeat: false, manuscript: 0, fragments: 0 }
530
+
531
+ // 增加所有带保底物品的保底计数
532
+ for (const it of allItems) {
533
+ if (it.pityStart) state.pityCounts[it.id || it.name] = (state.pityCounts[it.id || it.name] || 0) + 1
534
+ }
535
+
536
+ const maxCount = (it) => {
537
+ const key = it.id || it.name
538
+ return (state.itemCounts[key] || 0) >= (it.maxCount || it.max || Infinity)
539
+ }
540
+ const effWeight = (it) => {
541
+ if (!it.pityStart) return it.weight || 1
542
+ const pc = state.pityCounts[it.id || it.name] || 0
543
+ if (pc <= it.pityStart) return it.weight || 1
544
+ return (it.weight || 1) + (pc - it.pityStart) * (it.pityInc || 10)
545
+ }
546
+
547
+ // 保底触发检查
548
+ for (const it of allItems) {
549
+ if (it.pityGuarantee && !maxCount(it)) {
550
+ const pc = state.pityCounts[it.id || it.name] || 0
551
+ if (pc >= it.pityGuarantee) return applyJiayeItem(pool, it, state, user, true)
552
+ }
553
+ }
554
+
555
+ // 加权随机
556
+ const available = []
557
+ let weightSum = 0
558
+ for (const it of allItems) {
559
+ if (maxCount(it)) continue
560
+ const w = effWeight(it)
561
+ available.push({ item: it, weight: w })
562
+ weightSum += w
563
+ }
564
+ if (weightSum <= 0) {
565
+ const last = allItems[allItems.length - 1]
566
+ return applyJiayeItem(pool, last, state, user, false)
567
+ }
568
+ let rand = Math.random() * weightSum
569
+ let selected = available[available.length - 1]
570
+ for (const a of available) {
571
+ rand -= a.weight
572
+ if (rand < 0) { selected = a; break }
573
+ }
574
+ return applyJiayeItem(pool, selected.item, state, user, false)
575
+ }
576
+
577
+ function applyJiayeItem(pool, it, state, user, guaranteed) {
578
+ const key = it.id || it.name
579
+ if (it.pityStart) state.pityCounts[key] = 0
580
+ const currentCount = state.itemCounts[key] || 0
581
+ let manuscript = 0
582
+ let fragments = 0
583
+ if (it.isManuscriptDrop) {
584
+ manuscript += 20
585
+ state.itemCounts[key] = currentCount + 1
586
+ } else if (currentCount >= (it.maxCount || it.max || Infinity)) {
587
+ manuscript += 20
588
+ } else {
589
+ state.itemCounts[key] = currentCount + 1
590
+ }
591
+ state.manuscript = (state.manuscript || 0) + 5
592
+ manuscript += 5
593
+ const final = { ...it, rarity: it.rarity || 'D', isGuaranteed: guaranteed }
594
+ return { item: final, isRepeat: currentCount >= (it.maxCount || it.max || Infinity), manuscript, fragments }
595
+ }
596
+
597
+ // ===== 抽卡入口 =====
598
+
599
+ const DRAWERS = {
600
+ jinghua: { draw: v1Draw, prob: true, isOld: false },
601
+ paivei: { draw: v2Draw, prob: true, isOld: false },
602
+ xingyun: { draw: xingyunDraw, prob: true, isOld: false },
603
+ jianying: { draw: jyxbDraw, prob: true, isOld: true },
604
+ huanxiang: { draw: hxmtDraw, prob: true, isOld: false },
605
+ jiaye: { draw: jiayeDraw, prob: false, isOld: false },
606
+ }
607
+
608
+ async function gachaPull(config, store, userId, poolType, poolQuery, count) {
609
+ const type = POOL_TYPE_ALIAS[poolType] || poolType
610
+ if (!POOL_TYPES[type]) return { ok: false, error: '未知的抽卡类型:' + poolType }
611
+ const times = Math.min(10, Math.max(1, parseInt(count) || 10))
612
+
613
+ // 卡池未指定时使用配置中的默认卡池(幻象迷途固定 HXMT 无需配置)
614
+ if (type !== 'huanxiang' && (!poolQuery || !String(poolQuery).trim())) {
615
+ const defKey = 'gachaDefault' + type.charAt(0).toUpperCase() + type.slice(1)
616
+ poolQuery = (config && config[defKey]) || ''
617
+ if (!poolQuery) {
618
+ return { ok: false, error: '未指定卡池,请在配置中设置「抽卡-' + (POOL_TYPES[type].label || type) + ' 默认卡池编号」或手动指定' }
619
+ }
620
+ }
621
+
622
+ const src = await resolveSource(config)
623
+ const list = await loadPoolList(src.base, type, config.timeout)
624
+ let poolId = null
625
+ if (type === 'huanxiang') {
626
+ poolId = 'HXMT'
627
+ } else {
628
+ poolId = findPoolId(list, poolQuery)
629
+ if (!poolId) {
630
+ const names = list.map(x => x.id).join('、')
631
+ return { ok: false, error: '未找到卡池「' + poolQuery + '」,可用:' + names }
632
+ }
633
+ }
634
+
635
+ const pool = await loadPoolConfig(src.base, type, poolId, config.timeout)
636
+ const prob = await loadProbConfig(src.base, type, poolId, config.timeout)
637
+ const drawer = DRAWERS[type]
638
+
639
+ const user = await store.getUser(userId)
640
+ if (!user.pools[poolId]) user.pools[poolId] = defaultPoolState()
641
+ const state = user.pools[poolId]
642
+
643
+ // 每日次数限制:每种抽卡类型每天最多 limit 次(可配置)
644
+ const limit = Math.max(1, Number(config.gachaDailyLimit) || 2)
645
+ const today = new Date()
646
+ const todayStr = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0')
647
+ if (!user.daily) user.daily = {}
648
+ const daily = user.daily[type] || { date: todayStr, count: 0 }
649
+ if (daily.date !== todayStr) { daily.date = todayStr; daily.count = 0 }
650
+ if (daily.count >= limit) {
651
+ return { ok: false, error: '今日' + (POOL_TYPES[type].label || type) + '抽卡次数已达上限(每种每天限 ' + limit + ' 次),明天再来吧' }
652
+ }
653
+
654
+ const results = []
655
+ let totalFragments = 0, totalClue = 0, totalNightingale = 0, totalManuscript = 0
656
+ for (let i = 0; i < times; i++) {
657
+ const res = drawer.draw(pool, prob, state, user, drawer.isOld)
658
+ results.push(res)
659
+ totalFragments += res.fragments || 0
660
+ totalClue += res.clue || 0
661
+ totalNightingale += res.nightingale || 0
662
+ totalManuscript += res.manuscript || 0
663
+ }
664
+
665
+ if (totalFragments > 0) user.resources.fragments = (user.resources.fragments || 0) + totalFragments
666
+ if (totalClue > 0) user.resources.clue = (user.resources.clue || 0) + totalClue
667
+ if (totalNightingale > 0) user.resources.nightingale = (user.resources.nightingale || 0) + totalNightingale
668
+ if (totalManuscript > 0) user.resources.manuscript = (user.resources.manuscript || 0) + totalManuscript
669
+
670
+ // HXMT 每抽返还 + 里程碑奖励
671
+ if (type === 'huanxiang') {
672
+ const pullReward = pool.pullReward || 20
673
+ const totalPullReward = times * pullReward
674
+ user.resources.nightingale = (user.resources.nightingale || 0) + totalPullReward
675
+ totalNightingale += totalPullReward
676
+ let milestoneBonus = 0
677
+ for (const m of pool.milestoneRewards || []) {
678
+ if (state.total >= m.pulls && (state.lastMilestone || 0) < m.pulls) milestoneBonus += m.reward
679
+ }
680
+ state.lastMilestone = (pool.milestoneRewards || []).filter(m => state.total >= m.pulls).reduce((mx, m) => Math.max(mx, m.pulls), state.lastMilestone || 0)
681
+ if (milestoneBonus > 0) {
682
+ user.resources.nightingale = (user.resources.nightingale || 0) + milestoneBonus
683
+ totalNightingale += milestoneBonus
684
+ state.milestoneBonus = milestoneBonus
685
+ }
686
+ }
687
+
688
+ // 记录本次抽卡次数
689
+ daily.count++
690
+ user.daily[type] = daily
691
+
692
+ await store.save(userId)
693
+
694
+ const rarityMap = { S: '稀世', A: '奇珍', B: '独特', C: '罕见', D: '普通', F: '头像框' }
695
+ const itemList = results.map(r => {
696
+ const item = { ...r.item }
697
+ item.rarityLabel = rarityMap[item.rarity] || item.rarity
698
+ item.isRepeat = r.isRepeat
699
+ item.gainFragments = r.fragments || 0
700
+ item.gainClue = r.clue || 0
701
+ item.gainNightingale = r.nightingale || 0
702
+ item.gainManuscript = r.manuscript || 0
703
+ return item
704
+ })
705
+
706
+ return {
707
+ ok: true,
708
+ poolId,
709
+ poolName: pool.name || poolId,
710
+ type,
711
+ times,
712
+ dailyLeft: limit - daily.count,
713
+ items: itemList,
714
+ totalFragments, totalClue, totalNightingale, totalManuscript,
715
+ pity: {
716
+ gold: state.pityGold, purple: state.pityPurple, blue: state.pityBlue, frame: state.pityFrame, total: state.total,
717
+ },
718
+ }
719
+ }
720
+
721
+ // ===== 资源查询 =====
722
+
723
+ const RARITY_LABEL = { S: '稀世', A: '奇珍', B: '独特', C: '罕见', D: '普通', F: '头像框' }
724
+ const RARITY_ORDER = { S: 0, A: 1, B: 2, C: 3, D: 4, F: 5 }
725
+
726
+ async function gachaResources(store, userId) {
727
+ const user = await store.getUser(userId)
728
+ const out = []
729
+ for (const key of Object.keys(RESOURCE_TYPES)) {
730
+ const val = user.resources[key] || 0
731
+ out.push({ key, name: RESOURCE_TYPES[key], value: val })
732
+ }
733
+ // 已抽到的物品(新格式:对象含 name/rarity/count/img;旧格式 true 则从 key 中解析)
734
+ const owned = []
735
+ for (const k of Object.keys(user.owned || {})) {
736
+ const v = user.owned[k]
737
+ if (v && typeof v === 'object') {
738
+ owned.push({ name: v.name || k.split('|')[0], rarity: v.rarity || k.split('|')[2] || '', img: v.img || k.split('|')[3] || '', count: v.count || 1 })
739
+ } else {
740
+ const parts = k.split('|')
741
+ owned.push({ name: parts[0], rarity: parts[2] || '', img: parts[3] || '', count: 1 })
742
+ }
743
+ }
744
+ owned.sort((a, b) => (RARITY_ORDER[a.rarity] ?? 9) - (RARITY_ORDER[b.rarity] ?? 9) || String(a.name).localeCompare(String(b.name), 'zh'))
745
+ return { ok: true, resources: out, owned }
746
+ }
747
+
748
+ // ===== 抽卡资源/已抽物品 图片 HTML =====
749
+
750
+ function buildResourcesHtml(data, config) {
751
+ const width = config.gachaWidth || config.width || 1300
752
+ const cardW = Math.min(width, 1700)
753
+ const colors = {
754
+ S: '#e8c668', A: '#b568e8', B: '#689de8', C: '#65a30d', D: '#9ca3af', F: '#689de8',
755
+ }
756
+ const labels = {
757
+ S: '稀世', A: '奇珍', B: '独特', C: '罕见', D: '普通', F: '头像框',
758
+ }
759
+
760
+ const resHtml = (data.resources || []).map(r =>
761
+ '<span class="res">' + esc(r.name) + ' <b>' + r.value + '</b></span>'
762
+ ).join('')
763
+
764
+ const owned = data.owned || []
765
+ const colNum = owned.length <= 3 ? (owned.length || 1) : 10
766
+ const cardHtml = owned.map(it => {
767
+ const color = colors[it.rarity] || '#9ca3af'
768
+ const label = labels[it.rarity] || it.rarity
769
+ const imgHtml = it.img
770
+ ? '<img src="' + esc(it.img) + '" onerror="this.style.visibility=\'hidden\'">'
771
+ : '<div class="noimg">' + esc(String(it.name).charAt(0)) + '</div>'
772
+ return '<div class="card" style="--c:' + color + '">' +
773
+ '<div class="card-img">' + imgHtml + '</div>' +
774
+ '<div class="card-name">' + esc(it.name) + '</div>' +
775
+ '<div class="card-type" style="color:' + color + '">' + esc(label) + (it.count > 1 ? ' · ×' + it.count : '') + '</div>' +
776
+ '</div>'
777
+ }).join('')
778
+
779
+ const emptyHtml = owned.length === 0
780
+ ? '<div class="empty">还没有抽到任何物品,快去抽卡吧!</div>'
781
+ : ''
782
+
783
+ return `<!DOCTYPE html>
784
+ <html lang="zh-CN">
785
+ <head>
786
+ <meta charset="UTF-8">
787
+ <style>
788
+ * { margin:0; padding:0; box-sizing:border-box; }
789
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:30px 20px; color:#e6edf3;
790
+ background:radial-gradient(1200px 600px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); }
791
+ .page { max-width:${cardW}px; margin:0 auto; }
792
+ .head { text-align:center; margin-bottom:20px; }
793
+ .title { font-size:26px; font-weight:800; letter-spacing:1px; color:#fff;
794
+ background:linear-gradient(90deg, #79c0ff, #ffd76d); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
795
+ .sub { font-size:13px; color:#8b949e; margin-top:6px; }
796
+ .res-bar { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-bottom:22px; }
797
+ .res { font-size:13px; color:#8b949e; background:rgba(139,148,158,.08); border:1px solid rgba(139,148,158,.25); border-radius:8px; padding:5px 14px; }
798
+ .res b { color:#e6edf3; margin-left:4px; }
799
+ .grid { display:grid; grid-template-columns:repeat(${colNum}, 1fr); gap:14px; }
800
+ .card { background:rgba(22,27,34,.75); border:1px solid rgba(255,255,255,.09); border-radius:14px; padding:14px 12px;
801
+ text-align:center; overflow:hidden; }
802
+ .card-img { width:100%; aspect-ratio:1/1; background:#111318; border-radius:10px; display:flex; align-items:center; justify-content:center; overflow:hidden; margin-bottom:10px; }
803
+ .card-img img { max-width:100%; max-height:100%; object-fit:contain; filter:drop-shadow(0 0 10px var(--c)) drop-shadow(0 0 20px var(--c)); }
804
+ .noimg { width:100%; height:100%; display:flex; align-items:center; justify-content:center; font-size:34px; font-weight:800; color:var(--c); }
805
+ .card-name { font-size:14px; font-weight:700; color:#fff; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
806
+ .card-type { font-size:12px; }
807
+ .empty { text-align:center; color:#8b949e; font-size:15px; padding:40px 0; }
808
+ .foot { text-align:center; color:#8b949e; font-size:12px; margin-top:18px; opacity:.75; }
809
+ </style>
810
+ </head>
811
+ <body>
812
+ <div class="page">
813
+ <div class="head">
814
+ <div class="title">抽卡资源</div>
815
+ <div class="sub">已抽物品 ${owned.length} 种</div>
816
+ </div>
817
+ <div class="res-bar">${resHtml}</div>
818
+ ${emptyHtml}
819
+ <div class="grid">${cardHtml}</div>
820
+ <div class="foot">抽卡模拟</div>
821
+ </div>
822
+ </body>
823
+ </html>`
824
+ }
825
+
826
+ // 重置用户今日抽卡/探索次数为默认值(保留全部资源与卡池进度)
827
+ async function gachaResetDaily(store, userId) {
828
+ const user = await store.getUser(userId)
829
+ if (!user.daily) user.daily = {}
830
+ const today = new Date()
831
+ const todayStr = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0')
832
+ for (const key of Object.keys(user.daily)) {
833
+ user.daily[key] = { date: todayStr, count: 0 }
834
+ }
835
+ await store.save(userId)
836
+ return { ok: true }
837
+ }
838
+
839
+ // ===== 抽卡结果图片 HTML =====
840
+
841
+ function buildGachaHtml(data, config) {
842
+ const width = config.gachaWidth || config.width || 1300
843
+ const cardW = Math.min(width, 1100)
844
+ const colors = {
845
+ S: '#e8c668', A: '#b568e8', B: '#689de8', C: '#65a30d', D: '#9ca3af', F: '#689de8',
846
+ senior: '#e8c668', intermediate: '#689de8', junior: '#10b981', basic: '#6b7280', discount: '#f59e0b',
847
+ }
848
+ const labels = {
849
+ S: '稀世', A: '奇珍', B: '独特', C: '罕见', D: '普通', F: '头像框',
850
+ senior: '稀世', intermediate: '奇珍', junior: '罕见', basic: '普通', discount: '折扣',
851
+ }
852
+
853
+ const colNum = data.items.length === 1 ? 1 : (data.items.length <= 3 ? data.items.length : 5)
854
+ const cardHtml = data.items.map((it, idx) => {
855
+ const color = colors[it.rarity] || '#9ca3af'
856
+ const label = labels[it.rarity] || it.rarityLabel || it.rarity
857
+ const repeatHtml = it.isRepeat
858
+ ? '<div class="repeat">重复转化' + (it.gainFragments || it.gainManuscript || it.gainNightingale || it.gainClue ? ' <b>+' + (it.gainFragments || it.gainManuscript || it.gainNightingale || it.gainClue) + (it.gainFragments ? '碎片' : it.gainManuscript ? '手稿' : it.gainNightingale ? '夜莺祝福' : '线索') + '</b>' : '') + '</div>'
859
+ : ''
860
+ return '<div class="card" style="--c:' + color + '">' +
861
+ '<div class="card-img"><img src="' + esc(it.img || it.icon || '') + '" onerror="this.style.visibility=\'hidden\'"></div>' +
862
+ '<div class="card-name">' + esc(it.name) + '</div>' +
863
+ '<div class="card-type" style="color:' + color + '">' + esc(label) + (it.type ? ' · ' + esc(it.type) : '') + '</div>' +
864
+ repeatHtml +
865
+ '</div>'
866
+ }).join('')
867
+
868
+ const gains = []
869
+ if (data.totalFragments) gains.push('碎片 +' + data.totalFragments)
870
+ if (data.totalClue) gains.push('线索 +' + data.totalClue)
871
+ if (data.totalNightingale) gains.push('夜莺祝福 +' + data.totalNightingale)
872
+ if (data.totalManuscript) gains.push('未竟手稿 +' + data.totalManuscript)
873
+ const gainHtml = gains.length ? '<div class="gains">' + gains.map(g => '<span>' + g + '</span>').join('') + '</div>' : ''
874
+
875
+ return `<!DOCTYPE html>
876
+ <html lang="zh-CN">
877
+ <head>
878
+ <meta charset="UTF-8">
879
+ <style>
880
+ * { margin:0; padding:0; box-sizing:border-box; }
881
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:30px 20px; color:#e6edf3;
882
+ background:radial-gradient(1200px 600px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); }
883
+ .page { max-width:${cardW}px; margin:0 auto; }
884
+ .head { text-align:center; margin-bottom:20px; }
885
+ .pool-name { font-size:26px; font-weight:800; letter-spacing:1px; color:#fff;
886
+ background:linear-gradient(90deg, #79c0ff, #ffd76d); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
887
+ .pool-sub { font-size:13px; color:#8b949e; margin-top:6px; }
888
+ .grid { display:grid; grid-template-columns:repeat(${colNum}, 1fr); gap:14px; }
889
+ .card { background:rgba(22,27,34,.75); border:1px solid rgba(255,255,255,.09); border-radius:14px; padding:14px 12px;
890
+ text-align:center; overflow:hidden; }
891
+ .card-img { width:100%; aspect-ratio:1/1; background:#111318; border-radius:10px; display:flex; align-items:center; justify-content:center; overflow:hidden; margin-bottom:10px; }
892
+ .card-img img { max-width:100%; max-height:100%; object-fit:contain; filter:drop-shadow(0 0 10px var(--c)) drop-shadow(0 0 20px var(--c)); }
893
+ .card-name { font-size:14px; font-weight:700; color:#fff; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
894
+ .card-type { font-size:12px; }
895
+ .repeat { margin-top:6px; font-size:11.5px; color:#f85149; background:rgba(248,81,73,.08); border:1px solid rgba(248,81,73,.2); border-radius:6px; padding:3px 6px; }
896
+ .gains { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:20px; }
897
+ .gains span { font-size:13px; font-weight:700; color:#3fb950; background:rgba(63,185,80,.08); border:1px solid rgba(63,185,80,.25); border-radius:8px; padding:5px 14px; }
898
+ .foot { text-align:center; color:#8b949e; font-size:12px; margin-top:18px; opacity:.75; }
899
+ .pity { text-align:center; color:#8b949e; font-size:12px; margin-top:8px; }
900
+ .daily { display:inline-block; font-size:12.5px; color:#8b949e; background:rgba(139,148,158,.08); border:1px solid rgba(139,148,158,.25);
901
+ border-radius:8px; padding:4px 12px; margin-top:10px; }
902
+ </style>
903
+ </head>
904
+ <body>
905
+ <div class="page">
906
+ <div class="head">
907
+ <div class="pool-name">${esc(data.poolName)}</div>
908
+ <div class="pool-sub">抽取 ${data.times} 次</div>
909
+ <div>
910
+ <span class="daily">今日${esc(POOL_TYPES[data.type] ? POOL_TYPES[data.type].label : data.type)}剩余次数:${data.dailyLeft}</span>
911
+ </div>
912
+ </div>
913
+ <div class="grid">${cardHtml}</div>
914
+ ${gainHtml}
915
+ <div class="pity">保底进度:稀世 ${data.pity.gold} 抽 / 奇珍 ${data.pity.purple} 抽 / 独特 ${data.pity.blue} 抽 / 累计 ${data.pity.total} 抽</div>
916
+ <div class="foot">抽卡模拟</div>
917
+ </div>
918
+ </body>
919
+ </html>`
920
+ }
921
+
922
+ // ===== 加页手记·探索 =====
923
+
924
+ const EXPLORE_DIFFS = ['新手', '简单', '普通', '困难']
925
+
926
+ async function loadExploreData(base, timeout) {
927
+ const [weapons, cizhang, cfg, monsters, shenshi, miyan] = await Promise.all([
928
+ fetchJson(url(base, '/抽卡/jiayeshouji/weapon.json'), timeout).catch(() => []),
929
+ fetchJson(url(base, '/抽卡/jiayeshouji/cizhang.json'), timeout).catch(() => []),
930
+ fetchJson(url(base, '/抽卡/jiayeshouji/explore.json'), timeout).catch(() => ({})),
931
+ fetchJson(url(base, '/抽卡/jiayeshouji/monster.json'), timeout).catch(() => []),
932
+ fetchJson(url(base, '/抽卡/jiayeshouji/shenshi.json'), timeout).catch(() => []),
933
+ fetchJson(url(base, '/抽卡/jiayeshouji/miyan.json'), timeout).catch(() => ({})),
934
+ ])
935
+ return { weapons, cizhang, cfg, monsters, shenshi, miyan }
936
+ }
937
+
938
+ // 复刻 explore.html 的完整探索模拟(自动模式:自动交换、自动夜莺、快速完成)
939
+ function exploreRun(data, diff, equipped) {
940
+ const { weapons, cizhang, cfg, monsters, shenshi, miyan } = data
941
+ const log = []
942
+ const push = (msg, cls) => log.push({ msg, cls: cls || '' })
943
+ const rnd = (min, max) => min + Math.floor(Math.random() * (max - min + 1))
944
+ const fmt = n => (n || 0).toLocaleString()
945
+
946
+ // 难度
947
+ const costs = cfg.difficultyCosts || {}
948
+ const diffCost = costs[diff] || 0
949
+ const te = (cfg.totalEvents && cfg.totalEvents[diff]) || { min: 15, max: 30 }
950
+ const totalEvents = Math.max(3, rnd(te.min, te.max))
951
+ const minVal = (cfg.minCizhangValue || {})[diff] || 0
952
+ const em = cfg.equipmentMultipliers || {}
953
+ const lightSources = em.lightSources || ['提灯', '谎言之眼']
954
+ const hasLight = lightSources.some(s => equipped.includes(s))
955
+
956
+ // 装备效果叠乘
957
+ const weaponCfg = em.weapons || {}
958
+ let deathMult = 1, eventMult = 1
959
+ const noEquipMult = equipped.length === 0 ? (em.noEquipMultiplier || 1.5) : 1
960
+ const darkMult = hasLight ? 1 : (em.noLightPenalty || 1.3)
961
+ const equipSet = [...new Set(equipped)]
962
+ for (const name of equipSet) {
963
+ const c = weaponCfg[name]
964
+ if (!c) continue
965
+ const count = Math.min(c.maxStacks || 99, equipped.filter(n => n === name).length)
966
+ if (c.deathMult !== undefined) deathMult *= Math.pow(c.deathMult, count)
967
+ if (c.eventMult !== undefined) eventMult *= Math.pow(c.eventMult, count)
968
+ }
969
+ const totalEventsEff = Math.max(3, Math.round(totalEvents * eventMult))
970
+ const deathMultiplier = noEquipMult * deathMult * darkMult
971
+
972
+ // 装备价值(出货加成)
973
+ const equipTotal = equipped.reduce((s, n) => s + ((weapons.find(w => w.name === n) || {}).price || 0), 0)
974
+ const eb = cfg.equipBonus || { inkPerBonus: 10000, maxBonus: 30 }
975
+ const equipBonus = Math.min(eb.maxBonus || 30, Math.floor(equipTotal / (eb.inkPerBonus || 10000)))
976
+
977
+ // 状态
978
+ const st = {
979
+ events: 0, totalEvents: totalEventsEff, cizhang: [], hasSecret: false,
980
+ treasureChests: 0, dead: false, treasureRoomFound: false, treasureRoomPending: false,
981
+ pianoFound: false, charBlock: 0, smokePots: 0, smokeUsed: 0,
982
+ nightingaleUsed: false, nightingaleItem: null, killLog: {}, lostEquipValue: 0,
983
+ }
984
+ st.charBlock = equipSet.some(n => (weaponCfg[n] || {}).blockDeath) ? 1 : 0
985
+ st.smokePots = equipped.filter(n => n === '祝祷烟炉').length
986
+ const eventCounts = {}
987
+
988
+ push('📜 探索开始!难度:<b>' + diff + '</b>' + (diffCost > 0 ? '(入场费 ' + fmt(diffCost) + ' 墨迹)' : '') + ',事件数 ' + totalEventsEff)
989
+ push('🎒 携带装备:' + (equipped.length ? equipSet.join('、') : '无(怪物致命率×1.5)'))
990
+ if (!hasLight) push('⚠️ 未携带' + lightSources.join('/') + ':黑暗笼罩,死亡概率 ×' + darkMult)
991
+ if (st.charBlock > 0) push('🛡️ 角色守护:可抵挡 1 次致命伤害')
992
+ if (st.smokePots > 0) push('🕯 祝祷烟炉 ×' + st.smokePots + ':可抵挡致命伤害')
993
+ if (minVal > 0) push('⚠️ 需辞章价值达到 ' + fmt(minVal) + ' 墨迹才能成功撤离')
994
+ push('━━━━━━━━━━━━━━━━━━━━')
995
+
996
+ const addCizhang = (source) => {
997
+ const srcCfg = cfg[source] || {}
998
+ let diffProbs = (srcCfg.cizhangProbs && srcCfg.cizhangProbs[diff]) || (cfg.cizhangProbabilities || {})[diff] || { '华彩': 0.5, '稀世': 2, '奇珍': 8, '独特': 89.5 }
999
+ const adj = {
1000
+ '华彩': (diffProbs['华彩'] || 0) + equipBonus * ((eb.华彩Increment !== undefined ? eb.华彩Increment : 0.15) || 0),
1001
+ '稀世': (diffProbs['稀世'] || 0) + equipBonus * ((eb.稀世Increment !== undefined ? eb.稀世Increment : 0.3) || 0),
1002
+ '奇珍': (diffProbs['奇珍'] || 0) + equipBonus * ((eb.奇珍Increment !== undefined ? eb.奇珍Increment : 0.2) || 0),
1003
+ '独特': Math.max(0, (diffProbs['独特'] || 0) - equipBonus * Math.abs((eb.独特Decrement !== undefined ? eb.独特Decrement : 0.65) || 0)),
1004
+ }
1005
+ const total = adj['华彩'] + adj['稀世'] + adj['奇珍'] + adj['独特']
1006
+ if (total <= 0) return
1007
+ let r = Math.random() * total, rarity
1008
+ if (r < adj['华彩']) rarity = '华彩'
1009
+ else if (r < adj['华彩'] + adj['稀世']) rarity = '稀世'
1010
+ else if (r < adj['华彩'] + adj['稀世'] + adj['奇珍']) rarity = '奇珍'
1011
+ else rarity = '独特'
1012
+ const rate140w = cfg.rate140w !== undefined ? cfg.rate140w : 0.2
1013
+ if (rarity === '华彩' && Math.random() < rate140w) rarity = '华彩+'
1014
+ const pool = cizhang.filter(c => c.rarity === rarity)
1015
+ if (!pool.length) return
1016
+ const totalWeight = pool.reduce((s, c) => s + (c.weight || 1), 0)
1017
+ let rand = Math.random() * totalWeight
1018
+ const picked = pool.find(c => { rand -= (c.weight || 1); return rand < 0 }) || pool[0]
1019
+ st.cizhang.push(picked)
1020
+ push(' ↳ ' + picked.name + ' (' + rarity + '·' + fmt(picked.value) + '墨迹)', 'gain')
1021
+ // 夜莺自动带回:第一个超过 10 万价值的物品
1022
+ if (!st.nightingaleUsed && picked.value >= 100000) {
1023
+ st.nightingaleUsed = true
1024
+ st.nightingaleItem = picked
1025
+ st.cizhang = st.cizhang.filter(c => c !== picked)
1026
+ push('🕊 <b>夜莺</b>自动带回 <b>' + picked.name + '</b>(' + rarity + '·' + fmt(picked.value) + '墨迹),死亡也不影响!', 'night')
1027
+ }
1028
+ }
1029
+
1030
+ const pickMonster = (forcedName) => {
1031
+ let monster
1032
+ if (forcedName) monster = monsters.find(m => m.name === forcedName)
1033
+ else if (monsters.length) {
1034
+ const totalW = monsters.reduce((s, m) => s + ((m.weights && m.weights[diff]) || 0), 0)
1035
+ if (totalW > 0) {
1036
+ let r = Math.random() * totalW
1037
+ for (const m of monsters) {
1038
+ r -= (m.weights && m.weights[diff]) || 0
1039
+ if (r <= 0) { monster = m; break }
1040
+ }
1041
+ }
1042
+ if (!monster) monster = monsters[monsters.length - 1]
1043
+ }
1044
+ return monster
1045
+ }
1046
+
1047
+ const doCombat = (monster, mName, forced) => {
1048
+ const count = monster ? rnd((monster.countRange && monster.countRange[diff] || { min: 1, max: 1 }).min, (monster.countRange && monster.countRange[diff] || { min: 1, max: 1 }).max) : 1
1049
+ const secretMult = st.hasSecret ? 1.5 : 1
1050
+ const monsterKill = monster ? ((monster.killChance && monster.killChance[diff]) || 0.1) : 0.1
1051
+ const killChance = Math.min(1, monsterKill * deathMultiplier * secretMult)
1052
+ const encText = monster && monster.encounters && monster.encounters.length ? monster.encounters[Math.floor(Math.random() * monster.encounters.length)] : '遭遇 <b>' + mName + '</b>'
1053
+ const countText = count > 1 ? '你遇到了<b>' + mName + '</b>×' + count : '你遇到了<b>' + mName + '</b>'
1054
+ if (Math.random() < killChance) {
1055
+ if (st.charBlock > 0) {
1056
+ st.charBlock = 0
1057
+ push('👹 第' + st.events + '次:' + encText + countText + '——<span class="red">致命一击!</span>但被角色抵挡!', 'block')
1058
+ } else if (st.smokePots > 0) {
1059
+ st.smokePots--
1060
+ st.smokeUsed++
1061
+ push('👹 第' + st.events + '次:' + encText + countText + '——<span class="red">致命一击!</span>但被祝祷烟炉抵挡!(剩余 ' + st.smokePots + ' 个)', 'block')
1062
+ } else {
1063
+ st.dead = true
1064
+ st.lostEquipValue = equipped.filter(e => { const w = weapons.find(x => x.name === e); return w && w.type !== 'character' }).reduce((s, n) => s + ((weapons.find(x => x.name === n) || {}).price || 0), 0)
1065
+ push('👹 第' + st.events + '次:' + encText + countText + '——<span class="red">你死了!</span>装备全部丢失。', 'dead')
1066
+ }
1067
+ } else if (Math.random() < 0.5) {
1068
+ push('👹 第' + st.events + '次:' + encText + countText + '——<span class="green">击败怪物!</span>')
1069
+ st.killLog[mName] = (st.killLog[mName] || 0) + count
1070
+ } else {
1071
+ push('👹 第' + st.events + '次:' + encText + countText + '——成功逃脱!')
1072
+ }
1073
+ }
1074
+
1075
+ // 绅士交换(自动接受)
1076
+ const doExchange = (monster) => {
1077
+ if (!shenshi.length || !st.cizhang.length) return false
1078
+ const cheapest = [...st.cizhang].sort((a, b) => a.value - b.value)[0]
1079
+ const offer = shenshi[Math.floor(Math.random() * shenshi.length)]
1080
+ const idx = st.cizhang.indexOf(cheapest)
1081
+ if (idx !== -1) st.cizhang.splice(idx, 1)
1082
+ st.cizhang.push(offer)
1083
+ push('🤝 第' + st.events + '次:你给了缄默的绅士「' + cheapest.name + '」(' + cheapest.rarity + '·' + fmt(cheapest.value) + '),绅士回赠「' + offer.name + '」(' + offer.rarity + '·' + fmt(offer.value) + ')', 'ex')
1084
+ doCombat(monster, '缄默的绅士')
1085
+ return true
1086
+ }
1087
+
1088
+ // 秘言箱(自动:选最便宜独特 2~5 件提交)
1089
+ const doMiyan = () => {
1090
+ if (!miyan || !st.cizhang.length) {
1091
+ push('📦 第' + st.events + '次:发现秘言箱——但你没有可提交的物品。')
1092
+ return
1093
+ }
1094
+ const dudu = st.cizhang
1095
+ .map((c, i) => ({ ...c, _idx: i }))
1096
+ .filter(c => c.rarity === '独特')
1097
+ .sort((a, b) => a.value - b.value)
1098
+ .slice(0, 5)
1099
+ if (dudu.length < 2) {
1100
+ push('📦 第' + st.events + '次:发现秘言箱——但独特物品不足 2 件。')
1101
+ return
1102
+ }
1103
+ const selected = dudu.slice(0, Math.max(2, Math.min(5, dudu.length)))
1104
+ const indices = selected.map(c => c._idx).sort((a, b) => b - a)
1105
+ const offered = indices.map(i => st.cizhang[i])
1106
+ const totalValue = offered.reduce((s, c) => s + c.value, 0)
1107
+ indices.forEach(i => st.cizhang.splice(i, 1))
1108
+ const rarityOrder = ['独特', '奇珍', '稀世', '华彩']
1109
+ const maxRarity = offered.reduce((best, c) => {
1110
+ const ri = rarityOrder.indexOf(c.rarity)
1111
+ const bi = rarityOrder.indexOf(best)
1112
+ return ri > bi ? c.rarity : best
1113
+ }, '独特')
1114
+ const situation = miyan[maxRarity === '华彩' || maxRarity === '稀世' ? 'situation3' : maxRarity === '奇珍' ? 'situation2' : 'situation1']
1115
+ if (!situation) return
1116
+ let level = situation[0]
1117
+ for (const lv of situation) if (totalValue >= (lv.value || 0)) level = lv
1118
+ const probs = { ...level }
1119
+ delete probs.value; delete probs.combine_level; delete probs.desc
1120
+ const totalProb = Object.values(probs).reduce((s, v) => s + v, 0)
1121
+ let r = Math.random() * totalProb
1122
+ let pickedRarity = '独特'
1123
+ for (const [rar, prob] of Object.entries(probs)) {
1124
+ r -= prob
1125
+ if (r <= 0) { pickedRarity = rar; break }
1126
+ }
1127
+ let resultItem
1128
+ if (pickedRarity === '弧光') {
1129
+ resultItem = { name: '弧光', rarity: '奇珍', value: 5000 }
1130
+ } else {
1131
+ const pool = (miyan[pickedRarity] || []).map(c => ({ ...c }))
1132
+ for (const offeredItem of offered) {
1133
+ const sp = (miyan.special || []).find(s => s.name === offeredItem.name)
1134
+ if (sp) {
1135
+ const target = pool.find(c => c.name === sp.cizhang && c.rarity === sp.rarity)
1136
+ if (target) target.weight = (target.weight || 0) + (sp.weight || 0)
1137
+ }
1138
+ }
1139
+ const totalWeight = pool.reduce((s, c) => s + (c.weight || 1), 0)
1140
+ let rand = Math.random() * totalWeight
1141
+ resultItem = pool.find(c => { rand -= (c.weight || 1); return rand < 0 }) || pool[pool.length - 1]
1142
+ resultItem = { ...resultItem }
1143
+ }
1144
+ st.cizhang.push(resultItem)
1145
+ push('📦 第' + st.events + '次:你向秘言箱提交了 ' + offered.length + ' 件独特物品(总价值' + fmt(totalValue) + '),秘言箱回赠「' + resultItem.name + '」(' + resultItem.rarity + '·' + fmt(resultItem.value) + ')', 'ex')
1146
+ if (!st.nightingaleUsed && resultItem.value >= 100000) {
1147
+ st.nightingaleUsed = true
1148
+ st.nightingaleItem = resultItem
1149
+ st.cizhang = st.cizhang.filter(c => c !== resultItem)
1150
+ push('🕊 <b>夜莺</b>自动带回 <b>' + resultItem.name + '</b>(' + resultItem.rarity + '·' + fmt(resultItem.value) + '墨迹)', 'night')
1151
+ }
1152
+ }
1153
+
1154
+ const enterTreasureRoom = () => {
1155
+ st.treasureRoomFound = true
1156
+ st.treasureRoomPending = false
1157
+ st.hasSecret = false
1158
+ const secretIdx = st.cizhang.findIndex(c => c.name === '缪斯的秘密')
1159
+ if (secretIdx !== -1) st.cizhang.splice(secretIdx, 1)
1160
+ const chestCount = (cfg.treasureRoom && cfg.treasureRoom.chestCount) || 4
1161
+ push('🚪 第' + st.events + '次:进入宝藏房——消耗缪斯的秘密,打开' + chestCount + '个宝箱!', 'gold')
1162
+ for (let b = 1; b <= chestCount; b++) {
1163
+ push(' 📦 宝箱 #' + b + '——<span class="gold">获得辞章!</span>')
1164
+ addCizhang('treasureRoom')
1165
+ }
1166
+ }
1167
+
1168
+ // 事件循环
1169
+ while (st.events < st.totalEvents && !st.dead) {
1170
+ st.events++
1171
+ const baseWeights = (cfg.eventWeights || {})[diff] || { vase: 50, chest: 20, roadside: 15, piano: 5, treasureRoom: 3, monster: 22 }
1172
+ const caps = (cfg.eventCaps || {})[diff] || {}
1173
+ const eventTypes = ['vase', 'regularBox', 'roadside', 'piano', 'treasureRoom', 'antiqueCabinet1', 'antiqueCabinet2', 'exquisiteCabinet', 'miyan', 'monster']
1174
+ const weights = {}
1175
+ let totalWeight = 0
1176
+ for (const t of eventTypes) {
1177
+ const cap = caps[t]
1178
+ if (cap !== undefined && (eventCounts[t] || 0) >= cap) weights[t] = 0
1179
+ else weights[t] = baseWeights[t] || 0
1180
+ totalWeight += weights[t]
1181
+ }
1182
+ if (totalWeight <= 0) {
1183
+ push('第' + st.events + '次:所有事件已达上限,继续前行...')
1184
+ continue
1185
+ }
1186
+ const r = Math.random() * totalWeight
1187
+ let cum = 0, pickedType = 'monster'
1188
+ for (const t of eventTypes) {
1189
+ cum += weights[t]
1190
+ if (r < cum) { pickedType = t; break }
1191
+ }
1192
+ eventCounts[pickedType] = (eventCounts[pickedType] || 0) + 1
1193
+
1194
+ if (pickedType === 'vase') {
1195
+ if (Math.random() < ((cfg.vase && cfg.vase.cizhangChance && cfg.vase.cizhangChance[diff]) != null ? cfg.vase.cizhangChance[diff] : 0.25)) {
1196
+ push('🏺 第' + st.events + '次:打碎花瓶——<span class="gold">发现辞章!</span>')
1197
+ addCizhang('vase')
1198
+ } else {
1199
+ push('🏺 第' + st.events + '次:打碎花瓶——空无一物...')
1200
+ }
1201
+ } else if (pickedType === 'regularBox') {
1202
+ if (Math.random() < ((cfg.regularBox && cfg.regularBox.cizhangChance && cfg.regularBox.cizhangChance[diff]) != null ? cfg.regularBox.cizhangChance[diff] : 0.4)) {
1203
+ push('📦 第' + st.events + '次:打开普通的箱子——<span class="gold">发现辞章!</span>')
1204
+ addCizhang('regularBox')
1205
+ } else {
1206
+ push('📦 第' + st.events + '次:打开普通的箱子——空无一物...')
1207
+ }
1208
+ } else if (pickedType === 'antiqueCabinet1') {
1209
+ push('🗄 第' + st.events + '次:打开上锁的古董柜子——<span class="gold">获得辞章!</span>')
1210
+ addCizhang('antiqueCabinet1')
1211
+ if (Math.random() < ((cfg.antiqueCabinet1 && cfg.antiqueCabinet1.arcLightChance && cfg.antiqueCabinet1.arcLightChance[diff]) || 0)) {
1212
+ const arcItem = cizhang.find(c => c.name === '弧光')
1213
+ if (arcItem) { st.cizhang.push({ ...arcItem }); push(' ↳ <span class="purple">✨ 发现了额外的弧光!</span>', 'gain') }
1214
+ }
1215
+ } else if (pickedType === 'antiqueCabinet2') {
1216
+ push('🗄 第' + st.events + '次:打开上锁的古董柜子——<span class="gold">获得辞章!</span>')
1217
+ addCizhang('antiqueCabinet2')
1218
+ if (Math.random() < ((cfg.antiqueCabinet2 && cfg.antiqueCabinet2.arcLightChance && cfg.antiqueCabinet2.arcLightChance[diff]) || 0)) {
1219
+ const arcItem = cizhang.find(c => c.name === '弧光')
1220
+ if (arcItem) { st.cizhang.push({ ...arcItem }); push(' ↳ <span class="purple">✨ 发现了额外的弧光!</span>', 'gain') }
1221
+ }
1222
+ } else if (pickedType === 'exquisiteCabinet') {
1223
+ push('🗝 第' + st.events + '次:打开上锁的精致柜子——<span class="gold">获得辞章!</span>')
1224
+ addCizhang('exquisiteCabinet')
1225
+ if (Math.random() < ((cfg.exquisiteCabinet && cfg.exquisiteCabinet.arcLightChance && cfg.exquisiteCabinet.arcLightChance[diff]) || 0)) {
1226
+ const arcItem = cizhang.find(c => c.name === '弧光')
1227
+ if (arcItem) { st.cizhang.push({ ...arcItem }); push(' ↳ <span class="purple">✨ 发现了额外的弧光!</span>', 'gain') }
1228
+ }
1229
+ } else if (pickedType === 'roadside') {
1230
+ if (Math.random() < ((cfg.roadside && cfg.roadside.cizhangChance && cfg.roadside.cizhangChance[diff]) != null ? cfg.roadside.cizhangChance[diff] : 0.1)) {
1231
+ push('🍀 第' + st.events + '次:路边捡到物品——<span class="gold">发现辞章!</span>')
1232
+ addCizhang('roadside')
1233
+ } else {
1234
+ push('🍀 第' + st.events + '次:路边捡到物品——不值钱的小玩意儿...')
1235
+ }
1236
+ } else if (pickedType === 'piano') {
1237
+ if (st.pianoFound) {
1238
+ push('🎹 第' + st.events + '次:再次路过琴房——但已取走了缪斯的秘密。')
1239
+ } else {
1240
+ st.pianoFound = true
1241
+ st.hasSecret = true
1242
+ const secretValue = (cfg.piano && cfg.piano.rewardInk) || 10000
1243
+ st.cizhang.push({ name: '缪斯的秘密', rarity: '奇珍', value: secretValue })
1244
+ push('🎹 第' + st.events + '次:进入琴房——<span class="ms">获得「缪斯的秘密」!(价值' + fmt(secretValue) + '墨迹)</span>', 'gain')
1245
+ if (st.treasureRoomPending) {
1246
+ push(' ↳ <span class="gold">记起宝藏房位置,立刻折返!</span>')
1247
+ enterTreasureRoom()
1248
+ }
1249
+ }
1250
+ } else if (pickedType === 'treasureRoom') {
1251
+ if (st.treasureRoomFound || st.treasureRoomPending) {
1252
+ push('🚪 第' + st.events + '次:再次路过宝藏房——但已被搜刮一空。')
1253
+ } else if (!st.hasSecret) {
1254
+ st.treasureRoomPending = true
1255
+ push('🚪 第' + st.events + '次:发现宝藏房——<span class="red">但没有缪斯的秘密,标记位置等待钥匙...</span>')
1256
+ } else {
1257
+ enterTreasureRoom()
1258
+ }
1259
+ } else if (pickedType === 'miyan') {
1260
+ doMiyan()
1261
+ } else {
1262
+ // 怪物
1263
+ const monster = pickMonster()
1264
+ const mName = monster ? monster.name : '未知怪物'
1265
+ if (monster && mName === '缄默的绅士' && doExchange(monster)) continue
1266
+ if (monster && mName === '无名布偶') {
1267
+ const encText = monster.encounters && monster.encounters.length ? monster.encounters[Math.floor(Math.random() * monster.encounters.length)] : ''
1268
+ push('🧸 第' + st.events + '次:' + encText + '它抬起头盯着你,你感到一阵不安...')
1269
+ continue
1270
+ }
1271
+ doCombat(monster, mName)
1272
+ }
1273
+ }
1274
+
1275
+ // 结算
1276
+ push('━━━━━━━━━━━━━━━━━━━━')
1277
+ const nightingaleValue = st.nightingaleItem ? st.nightingaleItem.value : 0
1278
+ const result = { ok: true, diff, dead: false, success: false, carried: [], carriedValue: 0, nightingale: st.nightingaleItem, killLog: st.killLog, log }
1279
+ const ev = () => {
1280
+ const arcItems = st.cizhang.filter(c => c.name === '弧光')
1281
+ const nonArc = st.cizhang.filter(c => c.name !== '弧光')
1282
+ const sortedNonArc = [...nonArc].sort((a, b) => b.value - a.value)
1283
+ const top5 = sortedNonArc.slice(0, 5)
1284
+ const arcTotal = arcItems.reduce((s, c) => s + c.value, 0)
1285
+ return { arcItems, arcCount: arcItems.length, arcTotal, sortedNonArc, top5, top5Value: top5.reduce((s, c) => s + c.value, 0), effectiveTotal: arcTotal + top5.reduce((s, c) => s + c.value, 0) }
1286
+ }
1287
+
1288
+ if (st.dead) {
1289
+ result.dead = true
1290
+ push('<span class="red">💀 探索失败——装备丢失。</span>')
1291
+ if (st.lostEquipValue > 0) push('📦 损失装备价值:' + fmt(st.lostEquipValue) + ' 墨迹')
1292
+ if (diffCost > 0) push('💸 入场费 −' + fmt(diffCost) + ' 墨迹')
1293
+ if (nightingaleValue > 0) {
1294
+ push('🕊 夜莺带回了 <b>' + st.nightingaleItem.name + '</b>(' + st.nightingaleItem.rarity + '·+' + fmt(nightingaleValue) + '墨迹)', 'night')
1295
+ result.carried.push(st.nightingaleItem)
1296
+ result.carriedValue += nightingaleValue
1297
+ }
1298
+ } else {
1299
+ const e = ev()
1300
+ const top6NonArc = e.sortedNonArc.slice(0, 6)
1301
+ const top6Value = top6NonArc.reduce((s, c) => s + c.value, 0)
1302
+ const passWithArc = e.arcCount > 0 && e.effectiveTotal >= minVal
1303
+ const passWithoutArc = (nightingaleValue + top6Value) >= minVal
1304
+ if (!passWithArc && !passWithoutArc) {
1305
+ const totalCheck = Math.max(e.effectiveTotal, nightingaleValue + top6Value)
1306
+ push('<span class="red">⏳ 时间耗尽!背包总价值 ' + fmt(totalCheck) + ' 不足 ' + fmt(minVal) + ',未能撤离。</span>')
1307
+ push('<span class="red">💀 装备全部丢失。</span>')
1308
+ const lostEquip = equipped.filter(x => { const w = weapons.find(y => y.name === x); return w && w.type !== 'character' }).reduce((s, n) => s + ((weapons.find(x => x.name === n) || {}).price || 0), 0)
1309
+ if (lostEquip > 0) push('📦 损失装备价值:' + fmt(lostEquip) + ' 墨迹')
1310
+ if (diffCost > 0) push('💸 入场费 −' + fmt(diffCost) + ' 墨迹')
1311
+ if (nightingaleValue > 0) {
1312
+ push('🕊 夜莺带回了 <b>' + st.nightingaleItem.name + '</b>(+' + fmt(nightingaleValue) + '墨迹)', 'night')
1313
+ result.carried.push(st.nightingaleItem)
1314
+ result.carriedValue += nightingaleValue
1315
+ }
1316
+ } else {
1317
+ result.success = true
1318
+ const takeArc = passWithArc
1319
+ let totalValue = 0
1320
+ if (takeArc) {
1321
+ result.carried.push(...e.top5)
1322
+ if (e.arcCount > 0) result.carried.push({ name: '弧光 ×' + e.arcCount, rarity: '奇珍', value: e.arcTotal })
1323
+ totalValue = e.top5Value + e.arcTotal
1324
+ } else {
1325
+ result.carried.push(...top6NonArc)
1326
+ totalValue = top6Value
1327
+ }
1328
+ result.carriedValue = totalValue
1329
+ push('<span class="gold">🏆 探索成功!带出 ' + result.carried.length + ' 件辞章:</span>')
1330
+ result.carried.forEach(c => push(' · ' + c.name + ' (' + c.rarity + '·' + fmt(c.value) + '墨迹)'))
1331
+ push('💰 辞章价值 +' + fmt(totalValue) + ' 墨迹')
1332
+ const baseEquipValue = equipped.filter(x => { const w = weapons.find(y => y.name === x); return w && w.type !== 'character' }).reduce((s, n) => s + ((weapons.find(x => x.name === n) || {}).price || 0), 0)
1333
+ const smokeWep = weapons.find(x => x.name === '祝祷烟炉')
1334
+ const smokeDeduct = (smokeWep ? smokeWep.price : 0) * (st.smokeUsed || 0)
1335
+ const dep = cfg.equipDepreciation || { min: 0.05, max: 0.12 }
1336
+ const depRate = dep.min + Math.random() * (dep.max - dep.min)
1337
+ const depreciation = Math.round(baseEquipValue * depRate)
1338
+ if (baseEquipValue > 0) push('📦 装备带出 +' + fmt(baseEquipValue) + ' 墨迹')
1339
+ if (smokeDeduct > 0) push('🕯 祝祷烟炉消耗 −' + fmt(smokeDeduct) + ' 墨迹')
1340
+ if (depreciation > 0) push('🔧 装备维修 −' + fmt(depreciation) + ' 墨迹')
1341
+ if (diffCost > 0) push('💸 入场费 −' + fmt(diffCost) + ' 墨迹')
1342
+ if (nightingaleValue > 0) {
1343
+ push('🕊 夜莺带回了 <b>' + st.nightingaleItem.name + '</b>(+' + fmt(nightingaleValue) + '墨迹)', 'night')
1344
+ result.carriedValue += nightingaleValue
1345
+ }
1346
+ }
1347
+ }
1348
+ const killEntries = Object.entries(st.killLog || {})
1349
+ if (killEntries.length > 0) {
1350
+ killEntries.sort((a, b) => b[1] - a[1])
1351
+ push('━━━━━━━━━━━━━━━━━━━━')
1352
+ push('⚔️ <b>怪物击杀统计:</b>')
1353
+ killEntries.forEach(([name, count]) => push(' · ' + name + ' ×' + count))
1354
+ }
1355
+ result.totalEvents = st.totalEvents
1356
+ result.diffCost = diffCost
1357
+ result.minValue = minVal
1358
+ return result
1359
+ }
1360
+
1361
+ // 探索入口(每日限制 + 数据加载 + 模拟)
1362
+ async function gachaExplore(config, store, userId, diffArg) {
1363
+ const diff = EXPLORE_DIFFS.includes(String(diffArg || '').trim()) ? String(diffArg).trim() : '困难'
1364
+
1365
+ const user = await store.getUser(userId)
1366
+ if (!user.daily) user.daily = {}
1367
+ const today = new Date()
1368
+ const todayStr = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0')
1369
+ const limit = Math.max(1, Number(config.gachaDailyLimit) || 2)
1370
+ const daily = user.daily['explore'] || { date: todayStr, count: 0 }
1371
+ if (daily.date !== todayStr) { daily.date = todayStr; daily.count = 0 }
1372
+ if (daily.count >= limit) {
1373
+ return { ok: false, error: '今日探索次数已达上限(每天限 ' + limit + ' 次),明天再来吧' }
1374
+ }
1375
+
1376
+ const src = await resolveSource(config)
1377
+ const data = await loadExploreData(src.base, config.timeout)
1378
+ if (!data.weapons.length || !data.cizhang.length) {
1379
+ return { ok: false, error: '探索数据加载失败' }
1380
+ }
1381
+
1382
+ // 自动装备方案(保证可玩性:光源 + 角色 + 保命装)
1383
+ const equipped = ['信使', '提灯', '提神嗅盒', '提神嗅盒', '急救针', '祝祷烟炉']
1384
+
1385
+ const result = exploreRun(data, diff, equipped)
1386
+ daily.count++
1387
+ user.daily['explore'] = daily
1388
+ await store.save(userId)
1389
+
1390
+ result.dailyLeft = limit - daily.count
1391
+ result.diff = diff
1392
+ return result
1393
+ }
1394
+
1395
+ // 探索结果图片 HTML
1396
+ function buildExploreHtml(data, config) {
1397
+ const width = config.gachaWidth || config.width || 1300
1398
+ const cardW = Math.min(width, 1000)
1399
+ const lines = data.log.map(l => {
1400
+ let cls = 'line'
1401
+ if (l.cls === 'gain') cls = 'line gain'
1402
+ else if (l.cls === 'gold') cls = 'line gold'
1403
+ else if (l.cls === 'red' || l.cls === 'dead') cls = 'line red'
1404
+ else if (l.cls === 'green') cls = 'line green'
1405
+ else if (l.cls === 'night') cls = 'line night'
1406
+ else if (l.cls === 'ex') cls = 'line ex'
1407
+ else if (l.cls === 'block') cls = 'line block'
1408
+ else if (l.cls === 'purple') cls = 'line purple'
1409
+ else if (l.cls === 'ms') cls = 'line ms'
1410
+ return '<div class="' + cls + '">' + l.msg + '</div>'
1411
+ }).join('')
1412
+
1413
+ const statusHtml = data.dead
1414
+ ? '<div class="status bad">💀 探索失败</div>'
1415
+ : data.success
1416
+ ? '<div class="status good">🏆 探索成功</div>'
1417
+ : '<div class="status bad">⏳ 未能撤离</div>'
1418
+ const nightHtml = data.nightingale
1419
+ ? '<div class="night-bar">🕊 夜莺带回:<b>' + esc(data.nightingale.name) + '</b>(' + (data.nightingale.rarity || '') + '·' + esc(String(data.nightingale.value)) + '墨迹)</div>'
1420
+ : ''
1421
+
1422
+ return `<!DOCTYPE html>
1423
+ <html lang="zh-CN">
1424
+ <head>
1425
+ <meta charset="UTF-8">
1426
+ <style>
1427
+ * { margin:0; padding:0; box-sizing:border-box; }
1428
+ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; padding:28px 18px; color:#d1d5db;
1429
+ background:radial-gradient(1200px 600px at 50% -10%, #1b2a4a 0%, #10182b 45%, #0a0e17 100%); }
1430
+ .page { max-width:${cardW}px; margin:0 auto; }
1431
+ .head { text-align:center; margin-bottom:18px; }
1432
+ .title { font-size:26px; font-weight:800; letter-spacing:1px; color:#fff;
1433
+ background:linear-gradient(90deg, #79c0ff, #ffd76d); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
1434
+ .sub { font-size:13px; color:#8b949e; margin-top:6px; }
1435
+ .status { display:inline-block; font-size:14px; font-weight:700; border-radius:8px; padding:5px 16px; margin-top:10px; }
1436
+ .status.good { color:#3fb950; background:rgba(63,185,80,.1); border:1px solid rgba(63,185,80,.3); }
1437
+ .status.bad { color:#f85149; background:rgba(248,81,73,.1); border:1px solid rgba(248,81,73,.3); }
1438
+ .daily { display:inline-block; font-size:12.5px; color:#8b949e; background:rgba(139,148,158,.08); border:1px solid rgba(139,148,158,.25);
1439
+ border-radius:8px; padding:4px 12px; margin-left:8px; }
1440
+ .log { background:rgba(22,27,34,.75); border:1px solid rgba(255,255,255,.09); border-radius:14px; padding:18px 20px; }
1441
+ .line { font-size:13px; line-height:1.9; color:#c9d1d9; }
1442
+ .line.gain { color:#e6edf3; }
1443
+ .line.gold { color:#e8c668; }
1444
+ .line.red, .line.dead { color:#f85149; }
1445
+ .line.green { color:#3fb950; }
1446
+ .line.night { color:#b568e8; font-weight:700; }
1447
+ .line.ex { color:#ffd76d; }
1448
+ .line.block { color:#4ade80; }
1449
+ .line.purple { color:#b568e8; }
1450
+ .line.ms { color:#f0c060; }
1451
+ .line b { color:#fff; }
1452
+ .night-bar { margin-top:14px; font-size:13.5px; color:#b568e8; font-weight:700; text-align:center;
1453
+ background:rgba(181,104,232,.08); border:1px solid rgba(181,104,232,.3); border-radius:10px; padding:8px 14px; }
1454
+ .foot { text-align:center; color:#8b949e; font-size:12px; margin-top:16px; opacity:.75; }
1455
+ </style>
1456
+ </head>
1457
+ <body>
1458
+ <div class="page">
1459
+ <div class="head">
1460
+ <div class="title">加页手记·探索(${esc(data.diff)})</div>
1461
+ <div class="sub">事件 ${data.totalEvents} 次 · 入场费 ${esc(String(data.diffCost || 0))} 墨迹</div>
1462
+ <div>
1463
+ ${statusHtml}
1464
+ <span class="daily">今日剩余:${data.dailyLeft} 次</span>
1465
+ </div>
1466
+ </div>
1467
+ <div class="log">${lines}</div>
1468
+ ${nightHtml}
1469
+ <div class="foot">加页手记探索</div>
1470
+ </div>
1471
+ </body>
1472
+ </html>`
1473
+ }
1474
+
1475
+ // ===== 导出 =====
1476
+
1477
+ module.exports = {
1478
+ KNOWN_SOURCES,
1479
+ RESOURCE_TYPES,
1480
+ POOL_TYPES,
1481
+ POOL_TYPE_ALIAS,
1482
+ Store,
1483
+ resolveSource,
1484
+ gachaPull,
1485
+ gachaResources,
1486
+ gachaResetDaily,
1487
+ gachaExplore,
1488
+ buildGachaHtml,
1489
+ buildExploreHtml,
1490
+ buildResourcesHtml,
1491
+ exploreRun,
1492
+ loadExploreData,
1493
+ itemKey,
1494
+ markOwned,
1495
+ RARITY_LABEL,
1496
+ defaultPoolState,
1497
+ v1Draw,
1498
+ v2Draw,
1499
+ xingyunDraw,
1500
+ jyxbDraw,
1501
+ hxmtDraw,
1502
+ jiayeDraw,
1503
+ }