feat(loot): 完善首杀路由、种子锁规避与暂停继续交互 (Issue #110)

- 增强首杀/Bug杀模拟:支持 UI 首杀切换与 Boss 首杀 TC 自动路由(如 Baal -> Baalq)
- 修复无种子参数时 PRNG 零锁死导致的 100% NoDrop 偶发故障
- 增强逆向检索:掉落来源按综合概率降序排列并高亮展示
- 交互优化:新增蒙特卡洛模拟暂停/继续与优雅中断控制

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 15:05:37 +00:00
parent afc653dbc8
commit 3317ef0d90
3 changed files with 242 additions and 31 deletions

View File

@ -370,18 +370,30 @@
<div class="form-group">
<label>怪物 / 预设 TC (Monster / TC Preset)</label>
<select id="tc-preset">
<option value="Act 1 H2H A">Act 1 H2H A (第一幕近战小怪)</option>
<option value="Cow (H)">Cow (H) (地狱牛关母牛)</option>
<option value="Mephisto (H)">Mephisto (H) (地狱墨菲斯托)</option>
<option value="Andariel (H)">Andariel (H) (地狱安达利尔)</option>
<option value="Countess (H)">Countess (H) (地狱女伯爵)</option>
<option value="Baal (H)">Baal (H) (地狱毁灭之王巴尔)</option>
<option value="Act 1 H2H A">Act 1 H2H A (第一幕 近战小怪)</option>
<option value="Cow (H)">Cow (H) (秘密牛关 母牛)</option>
<option value="Cow King (H)">Cow King (H) (秘密牛关 牛王)</option>
<option value="Andariel (H)">Andariel (H) (第一幕 安达利尔)</option>
<option value="Countess (H)">Countess (H) (第一幕 女伯爵)</option>
<option value="Duriel (H)">Duriel (H) (第二幕 督瑞尔)</option>
<option value="Mephisto (H)">Mephisto (H) (第三幕 墨菲斯托)</option>
<option value="Council (H)">Council (H) (第三幕 议会成员/瘸子)</option>
<option value="Diablo (H)">Diablo (H) (第四幕 暗黑破坏神)</option>
<option value="Baal (H)">Baal (H) (第五幕 巴尔)</option>
<option value="Nihlathak (H)">Nihlathak (H) (第五幕 尼拉塞克)</option>
</select>
</div>
<div class="form-group">
<label>自定义 TC 名称 (Custom TC Input)</label>
<input type="text" id="tc-input" value="Act 1 H2H A" placeholder="例如 Act 1 H2H A, Cow (H)" />
<input type="text" id="tc-input" value="Act 1 H2H A" placeholder="例如 Act 1 H2H A, Cow (H), Baal (H)" />
</div>
<div class="form-group" style="margin-top: 2px;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--gold-light); font-size: 11px;">
<input type="checkbox" id="quest-drop" style="cursor: pointer;" />
<span>👑 Boss 首次击杀 / Bug 首杀 (Quest Drop)</span>
</label>
</div>
<div class="form-row">

View File

@ -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<string, AnalyticItemDrop>();
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<string, number> = {
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<void>,
): Promise<SimulationResult> {
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(`
<tr>
<td style="color: var(--gold-primary); font-weight: bold;">${entry.tcName}</td>
<td>${res.matchedCode} (${res.matchedName})</td>
<td>${entry.picks}</td>
<td>${(entry.singleProbability * 100).toFixed(4)}%</td>
<td style="color: #a5d6a7; font-weight: bold;">${pct}</td>
<td style="color: var(--gold-light); font-variant-numeric: tabular-nums;">${odds}</td>
</tr>
`);
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(`
<tr>
<td style="color: var(--gold-primary); font-weight: bold;">${entry.tcName}</td>
<td>${res.matchedCode} (${res.matchedName})</td>
<td>${entry.picks}</td>
<td>${(entry.singleProbability * 100).toFixed(4)}%</td>
<td style="color: #a5d6a7; font-weight: bold;">${pct}</td>
<td style="color: var(--gold-light); font-variant-numeric: tabular-nums;">${odds}</td>
</tr>
`);
}
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 = `<span style="color: #ff8080;">⚠️ 未找到名为 "<strong>${tcName}</strong>" 的 TreasureClass。请检查名称拼写或从左侧预设选择。</span>`;
}
if (exactTbody) {
exactTbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: #ff8080; padding: 20px;">未找到名为 "${tcName}" 的 TreasureClass,请检查拼写</td></tr>`;
}
return;
}
if (exactTcInfo) {
const isQuest = tcName.includes('q');
exactTcInfo.innerHTML = `
<strong>TC:</strong> ${result.tcName} |
<strong>TC:</strong> ${result.tcName}${isQuest ? ' <span style="color: #ffd700; font-weight: bold;">(👑首杀)</span>' : ''} |
<strong>Picks:</strong> ${result.picks} |
<strong>有效人数 (Effective Players):</strong> ${result.effectivePlayers} |
<strong>综合 NoDrop:</strong> ${(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 = `<tr><td colspan="5" style="text-align: center; color: var(--text-dim); padding: 16px;">无匹配的掉落物记录</td></tr>`;
return;
}
const rows = filtered.slice(0, 200).map(it => {
let color = '#c8c8c8';
if (it.quality === 'magic') color = '#4880ff';

View File

@ -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', () => {