From 3317ef0d901ffb8448171d38a112ebe19e80c73f Mon Sep 17 00:00:00 2001
From: troytt <47798984@qq.com>
Date: Fri, 18 Sep 2026 15:05:37 +0000
Subject: [PATCH] =?UTF-8?q?feat(loot):=20=E5=AE=8C=E5=96=84=E9=A6=96?=
=?UTF-8?q?=E6=9D=80=E8=B7=AF=E7=94=B1=E3=80=81=E7=A7=8D=E5=AD=90=E9=94=81?=
=?UTF-8?q?=E8=A7=84=E9=81=BF=E4=B8=8E=E6=9A=82=E5=81=9C=E7=BB=A7=E7=BB=AD?=
=?UTF-8?q?=E4=BA=A4=E4=BA=92=20(Issue=20#110)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 增强首杀/Bug杀模拟:支持 UI 首杀切换与 Boss 首杀 TC 自动路由(如 Baal -> Baalq)
- 修复无种子参数时 PRNG 零锁死导致的 100% NoDrop 偶发故障
- 增强逆向检索:掉落来源按综合概率降序排列并高亮展示
- 交互优化:新增蒙特卡洛模拟暂停/继续与优雅中断控制
TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
---
loot.html | 26 +++++--
src/loot.ts | 167 ++++++++++++++++++++++++++++++++++++++-------
tests/loot.test.ts | 80 ++++++++++++++++++++++
3 files changed, 242 insertions(+), 31 deletions(-)
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', () => {