koishi-plugin-dwrgbwiki 1.3.0 → 1.3.2

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