diff --git a/loot.html b/loot.html index ae69c78..ed570f7 100644 --- a/loot.html +++ b/loot.html @@ -370,18 +370,30 @@
- + +
+ +
+
diff --git a/src/loot.ts b/src/loot.ts index 67a4c32..9550d11 100644 --- a/src/loot.ts +++ b/src/loot.ts @@ -165,6 +165,7 @@ export interface SimulationOptions { readonly partyPlayers?: number | undefined; readonly magicFind?: number | undefined; readonly difficulty?: 'normal' | 'nightmare' | 'hell' | undefined; + readonly isQuestDrop?: boolean | undefined; readonly seed?: number | undefined; readonly chunkSize?: number | undefined; readonly tcTable?: TreasureClassTable | undefined; @@ -391,7 +392,7 @@ export function calculateAnalyticProbabilities( const leafMap = new Map(); for (const [code, pSingle] of leafSingleProb) { - const compound = calculateCompoundProbability(pSingle, rootPicks); + const compound = rootPicks < 0 ? Math.min(1, pSingle) : calculateCompoundProbability(pSingle, rootPicks); const expected = (rootPicks < 0 ? 1 : rootPicks) * pSingle; const odds = compound > 0 ? 1 / compound : Infinity; const isGold = code === 'gld'; @@ -518,18 +519,43 @@ export function reverseLookup( return results; } -// --- Monte Carlo Simulation -------------------------------------------------- +const QUALITY_RANK: Record = { + unique: 6, + set: 5, + rare: 4, + magic: 3, + superior: 2, + normal: 1, + gold: 0, + rune: 7, +}; export async function runMonteCarloSimulation( options: SimulationOptions, - onProgress?: (progress: SimulationProgress) => void, + onProgress?: (progress: SimulationProgress) => void | Promise, ): Promise { const tcTable = options.tcTable ?? loadCanonicalTreasureClasses().tcTable; const autoTcs = options.autoTcs ?? loadCanonicalTreasureClasses().autoTcs; + if (options.tcTable && options.autoTcs) { + mergeAutoTreasureClasses(tcTable, autoTcs); + } const itemRatio = options.itemRatioTable ?? createItemRatioTable(CANONICAL_ITEM_RATIO_ROWS); const ratioRow = itemRatio.getRow(1, 0, 0) ?? CANONICAL_ITEM_RATIO_ROWS[2]!; - const rng = options.seed !== undefined ? new D2Rng(options.seed) : new D2Rng(); + let effectiveTcName = options.tcName; + if (options.isQuestDrop) { + const questTc = effectiveTcName.replace(/^(Andariel|Duriel|Mephisto|Diablo|Baal)(.*)$/, '$1q$2'); + if (tcTable.get(questTc)) { + effectiveTcName = questTc; + } + } + + // Critical fix: When seed is undefined or 0, initialize with non-zero pseudorandom 32-bit integer + // to avoid D2 LCG (0 * multiplier + 0 = 0) permanent zero lock. + const initialSeed = (options.seed !== undefined && options.seed !== 0) + ? options.seed + : ((Math.floor(Math.random() * 0x7fffffff) + 1) >>> 0); + const rng = new D2Rng(initialSeed); const totalRuns = options.runs; const chunkSize = options.chunkSize ?? Math.max(100, Math.min(5000, Math.floor(totalRuns / 20))); @@ -559,7 +585,7 @@ export async function runMonteCarloSimulation( const currentChunk = Math.min(chunkSize, totalRuns - completedRuns); for (let i = 0; i < currentChunk; i++) { - const drops = rollTreasureClass(options.tcName, { + const drops = rollTreasureClass(effectiveTcName, { rng, tcTable, gamePlayers: options.gamePlayers ?? 1, @@ -616,6 +642,9 @@ export async function runMonteCarloSimulation( const existing = itemCounts.get(code); if (existing) { existing.count++; + if ((QUALITY_RANK[quality] ?? 0) > (QUALITY_RANK[existing.quality] ?? 0)) { + existing.quality = quality; + } } else { itemCounts.set(code, { count: 1, quality }); } @@ -625,7 +654,7 @@ export async function runMonteCarloSimulation( completedRuns += currentChunk; if (onProgress) { - onProgress({ + await onProgress({ completedRuns, totalRuns, itemsDropped: totalDropped, @@ -672,6 +701,7 @@ export function initLootUi(): void { const presetSelect = document.getElementById('tc-preset') as HTMLSelectElement | null; const tcInput = document.getElementById('tc-input') as HTMLInputElement | null; + const questDropCheckbox = document.getElementById('quest-drop') as HTMLInputElement | null; const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement | null; const gamePlayersInput = document.getElementById('game-players') as HTMLInputElement | null; const partyPlayersInput = document.getElementById('party-players') as HTMLInputElement | null; @@ -679,6 +709,17 @@ export function initLootUi(): void { const runsSelect = document.getElementById('sim-runs') as HTMLSelectElement | null; const seedInput = document.getElementById('sim-seed') as HTMLInputElement | null; + function getEffectiveTcName(): string { + let name = tcInput?.value.trim() || 'Act 1 H2H A'; + if (questDropCheckbox?.checked) { + const questTc = name.replace(/^(Andariel|Duriel|Mephisto|Diablo|Baal)(.*)$/, '$1q$2'); + if (tcTable.get(questTc)) { + return questTc; + } + } + return name; + } + const btnStart = document.getElementById('btn-start') as HTMLButtonElement | null; const btnPause = document.getElementById('btn-pause') as HTMLButtonElement | null; const btnStop = document.getElementById('btn-stop') as HTMLButtonElement | null; @@ -720,6 +761,9 @@ export function initLootUi(): void { presetSelect?.addEventListener('change', () => { if (presetSelect.value && tcInput) { tcInput.value = presetSelect.value; + if (questDropCheckbox) { + questDropCheckbox.checked = presetSelect.value.includes('q'); + } updateExactTable(); } }); @@ -728,7 +772,39 @@ export function initLootUi(): void { updateExactTable(); }); + questDropCheckbox?.addEventListener('change', () => { + if (tcInput) { + let val = tcInput.value.trim(); + if (questDropCheckbox.checked) { + val = val.replace(/^(Andariel|Duriel|Mephisto|Diablo|Baal)(.*)$/, '$1q$2'); + } else { + val = val.replace(/^(Andariel|Duriel|Mephisto|Diablo|Baal)q(.*)$/, '$1$2'); + } + if (tcTable.get(val)) { + tcInput.value = val; + } + } + updateExactTable(); + }); + difficultySelect?.addEventListener('change', () => { + if (tcInput) { + const diff = difficultySelect.value; + let val = tcInput.value.trim(); + if (diff === 'hell') { + if (val.includes(' (N)')) val = val.replace(' (N)', ' (H)'); + else if (!val.includes(' (H)') && tcTable.get(val + ' (H)')) val = val + ' (H)'; + } else if (diff === 'nightmare') { + if (val.includes(' (H)')) val = val.replace(' (H)', ' (N)'); + else if (!val.includes(' (N)') && tcTable.get(val + ' (N)')) val = val + ' (N)'; + } else if (diff === 'normal') { + const normVal = val.replace(/ \([NH]\)/g, ''); + if (tcTable.get(normVal)) val = normVal; + } + if (tcTable.get(val)) { + tcInput.value = val; + } + } updateExactTable(); }); @@ -778,23 +854,31 @@ export function initLootUi(): void { let totalMatches = 0; const rows: string[] = []; + const allMatches: { res: ReverseLookupResult; entry: ReverseLookupEntry }[] = []; for (const res of results) { for (const entry of res.entries) { - totalMatches++; - const pct = (entry.compoundProbability * 100).toFixed(4) + '%'; - const odds = entry.oneInOdds < 1000000 ? '1 in ' + entry.oneInOdds.toFixed(1) : '1 in ' + Math.round(entry.oneInOdds).toLocaleString(); - rows.push(` - - ${entry.tcName} - ${res.matchedCode} (${res.matchedName}) - ${entry.picks} - ${(entry.singleProbability * 100).toFixed(4)}% - ${pct} - ${odds} - - `); + allMatches.push({ res, entry }); } } + allMatches.sort((a, b) => b.entry.compoundProbability - a.entry.compoundProbability); + + for (const m of allMatches) { + totalMatches++; + const res = m.res; + const entry = m.entry; + const pct = (entry.compoundProbability * 100).toFixed(4) + '%'; + const odds = entry.oneInOdds < 1000000 ? '1 in ' + entry.oneInOdds.toFixed(1) : '1 in ' + Math.round(entry.oneInOdds).toLocaleString(); + rows.push(` + + ${entry.tcName} + ${res.matchedCode} (${res.matchedName}) + ${entry.picks} + ${(entry.singleProbability * 100).toFixed(4)}% + ${pct} + ${odds} + + `); + } reverseTbody.innerHTML = rows.slice(0, 200).join(''); if (reverseMatchCount) { @@ -803,7 +887,7 @@ export function initLootUi(): void { } function updateExactTable() { - const tcName = tcInput?.value.trim() || 'Act 1 H2H A'; + const tcName = getEffectiveTcName(); const g = parseInt(gamePlayersInput?.value || '1', 10) || 1; const p = parseInt(partyPlayersInput?.value || '1', 10) || 1; @@ -814,9 +898,20 @@ export function initLootUi(): void { partyPlayers: p, }); + if (result.leaves.length === 0 && !tcTable.get(tcName) && !autoTcs.has(tcName)) { + if (exactTcInfo) { + exactTcInfo.innerHTML = `⚠️ 未找到名为 "${tcName}" 的 TreasureClass。请检查名称拼写或从左侧预设选择。`; + } + if (exactTbody) { + exactTbody.innerHTML = `未找到名为 "${tcName}" 的 TreasureClass,请检查拼写`; + } + return; + } + if (exactTcInfo) { + const isQuest = tcName.includes('q'); exactTcInfo.innerHTML = ` - TC: ${result.tcName} | + TC: ${result.tcName}${isQuest ? ' (👑首杀)' : ''} | Picks: ${result.picks} | 有效人数 (Effective Players): ${result.effectivePlayers} | 综合 NoDrop: ${(result.noDropCompoundProb * 100).toFixed(2)}% | @@ -857,7 +952,12 @@ export function initLootUi(): void { btnStart?.addEventListener('click', async () => { if (isSimRunning) return; - const tcName = tcInput?.value.trim() || 'Act 1 H2H A'; + const tcName = getEffectiveTcName(); + if (!tcTable.get(tcName) && !autoTcs.has(tcName)) { + alert(`未找到名为 "${tcName}" 的 TreasureClass,请检查名称拼写!`); + return; + } + const runs = parseInt(runsSelect?.value || '10000', 10) || 10000; const g = parseInt(gamePlayersInput?.value || '1', 10) || 1; const p = parseInt(partyPlayersInput?.value || '1', 10) || 1; @@ -889,7 +989,14 @@ export function initLootUi(): void { tcTable, autoTcs, }, - progress => { + async progress => { + if (abortRequested) throw new Error('Simulation aborted'); + while (isSimPaused && !abortRequested) { + if (progressText) { + progressText.textContent = `已暂停 (${progress.completedRuns.toLocaleString()} / ${progress.totalRuns.toLocaleString()}) - 点击「继续」恢复`; + } + await new Promise(r => setTimeout(r, 50)); + } if (abortRequested) throw new Error('Simulation aborted'); renderProgress(progress); }, @@ -905,8 +1012,12 @@ export function initLootUi(): void { } } finally { isSimRunning = false; + isSimPaused = false; if (btnStart) btnStart.disabled = false; - if (btnPause) btnPause.disabled = true; + if (btnPause) { + btnPause.disabled = true; + btnPause.textContent = '⏸ 暂停'; + } if (btnStop) btnStop.disabled = true; } }); @@ -916,6 +1027,9 @@ export function initLootUi(): void { if (btnPause) { btnPause.textContent = isSimPaused ? '▶ 继续' : '⏸ 暂停'; } + if (progressText && isSimPaused) { + progressText.textContent = '已暂停 - 点击「继续」恢复'; + } }); btnStop?.addEventListener('click', () => { @@ -999,6 +1113,11 @@ export function initLootUi(): void { return s.code.toLowerCase().includes(filter) || s.name.toLowerCase().includes(filter) || s.quality.toLowerCase().includes(filter); }); + if (filtered.length === 0) { + itemsTbody.innerHTML = `无匹配的掉落物记录`; + return; + } + const rows = filtered.slice(0, 200).map(it => { let color = '#c8c8c8'; if (it.quality === 'magic') color = '#4880ff'; diff --git a/tests/loot.test.ts b/tests/loot.test.ts index d4082b6..71909ac 100644 --- a/tests/loot.test.ts +++ b/tests/loot.test.ts @@ -185,6 +185,86 @@ describe('Loot Drop Simulator & Exact Probability Table (Issue #110)', () => { expect(Math.abs(observedRate8 - expectedRate8)).toBeLessThanOrEqual(2.576 * se8) expect(observedRate8).toBeLessThan(0.20) }) + + it('simulates drops without seed (undefined seed) without PRNG zero-lock', async () => { + // Regression test for Issue #110 bug: When seed is omitted (default UI state), + // PRNG must not lock into (0, 0) state which produced 100% NoDrop across 1000 runs. + const runs = 1000 + const result = await runMonteCarloSimulation({ + tcName: 'Baal (H)', + runs, + gamePlayers: 1, + partyPlayers: 1, + tcTable, + autoTcs, + }) + + expect(result.totalRuns).toBe(runs) + expect(result.itemsDropped).toBeGreaterThan(2000) // Baal rolls up to 7 picks, usually ~5-6 items per run + expect(result.emptyRuns).toBeLessThan(10) // Baal has extremely low NoDrop + expect(result.itemSummaries.length).toBeGreaterThan(10) + expect(result.qualityCounts.magic + result.qualityCounts.rare + result.qualityCounts.set + result.qualityCounts.unique).toBeGreaterThan(0) + }) + + it('simulates drops with seed=0 safely mapping to non-zero seed', async () => { + const runs = 1000 + const result = await runMonteCarloSimulation({ + tcName: 'Baal (H)', + runs, + seed: 0, + gamePlayers: 1, + partyPlayers: 1, + tcTable, + autoTcs, + }) + + expect(result.totalRuns).toBe(runs) + expect(result.itemsDropped).toBeGreaterThan(2000) + expect(result.emptyRuns).toBeLessThan(10) + }) + + it('supports isQuestDrop option routing to Baalq (H)', async () => { + const runs = 500 + const result = await runMonteCarloSimulation({ + tcName: 'Baal (H)', + runs, + isQuestDrop: true, + gamePlayers: 1, + partyPlayers: 1, + tcTable, + autoTcs, + }) + + expect(result.totalRuns).toBe(runs) + expect(result.itemsDropped).toBeGreaterThan(1000) + // Quest drop has 0 NoDrop + expect(result.emptyRuns).toBe(0) + }) + + it('handles negative picks analytic probabilities without producing 100% compound for rare items', () => { + // Find a TC with negative picks in tcTable + let negativeTcName: string | undefined + for (const node of tcTable.all) { + if (node.picks < 0 && node.items.length > 1) { + negativeTcName = node.name + break + } + } + + if (negativeTcName) { + const analytic = calculateAnalyticProbabilities(negativeTcName, { + tcTable, + autoTcs, + gamePlayers: 1, + partyPlayers: 1, + }) + for (const leaf of analytic.leaves) { + if (leaf.singleProbability < 0.5) { + expect(leaf.compoundProbability).toBeLessThan(1.0) + } + } + } + }) }) describe('4. Reverse Lookup Indexing All Eligible Items', () => {