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:
parent
afc653dbc8
commit
3317ef0d90
26
loot.html
26
loot.html
|
|
@ -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">
|
||||
|
|
|
|||
167
src/loot.ts
167
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<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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue