fix(loot): 修复怪物选择器TC被Baal(H)死锁覆盖的缺陷 (Issue #121)
- 修复 getEffectiveTcName 中因比对 tcInput.value 产生误判,导致所有选定怪物均错误回退到初始 Baal (H) 的核心 Bug - 引入清晰独立的 customTcOverride 状态变量,仅在用户显式提交高级自定义 TC 时生效 - 在选择新怪物、切换难度或切换任务首杀状态时自动重置 customTcOverride,保证当前怪物真实 1.13c TC 生效 - 优化怪物卡片 Picks 次数与单次 NoDrop 比例展示,提升数据可读性 - 新增 tests/loot.test.ts 专项测试验证 Council、Andariel、Pindleskin 等所有预设怪物 TC 解析正确性
This commit is contained in:
parent
9b4347a72f
commit
c9a9a5558d
74
src/loot.ts
74
src/loot.ts
|
|
@ -1056,6 +1056,25 @@ export const MONSTER_PRESETS: readonly MonsterPreset[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves the effective TreasureClass name for a given monster preset, difficulty, and quest drop status.
|
||||
* If customOverride is provided, customOverride takes precedence.
|
||||
*/
|
||||
export function getMonsterPresetTc(
|
||||
monster: MonsterPreset,
|
||||
difficulty: 'normal' | 'nightmare' | 'hell',
|
||||
quest: boolean = false,
|
||||
customOverride?: string | null,
|
||||
): string {
|
||||
if (customOverride && customOverride.trim()) {
|
||||
return customOverride.trim();
|
||||
}
|
||||
if (quest && monster.questTc) {
|
||||
return monster.questTc[difficulty];
|
||||
}
|
||||
return monster.tc[difficulty];
|
||||
}
|
||||
|
||||
export type ItemCategoryFilter = 'all' | 'unique' | 'set' | 'rune' | 'base' | 'charm_gem' | 'misc';
|
||||
|
||||
export const FAMOUS_ITEMS_META: Readonly<Record<string, { famousTitle: string; itemTypeCn: string; qlvl?: number }>> = {
|
||||
|
|
@ -1160,6 +1179,7 @@ export function initLootUi(): void {
|
|||
let currentDifficulty: 'normal' | 'nightmare' | 'hell' = 'hell';
|
||||
let currentMonster: MonsterPreset = MONSTER_PRESETS.find(m => m.id === 'baal') ?? MONSTER_PRESETS[0]!;
|
||||
let isQuestDrop = false;
|
||||
let customTcOverride: string | null = null;
|
||||
let activeMonsterCategory: MonsterCategory | 'all' = 'all';
|
||||
let activeItemCategory: ItemCategoryFilter = 'all';
|
||||
let currentSortOrder: 'odds-desc' | 'odds-asc' | 'quality' | 'name' | 'qlvl' = 'odds-desc';
|
||||
|
|
@ -1248,13 +1268,7 @@ export function initLootUi(): void {
|
|||
const reverseMatchCount = document.getElementById('reverse-match-count');
|
||||
|
||||
function getEffectiveTcName(): string {
|
||||
if (tcInput && tcInput.value.trim() && tcInput.value.trim() !== currentMonster.tc[currentDifficulty]) {
|
||||
return tcInput.value.trim();
|
||||
}
|
||||
if (isQuestDrop && currentMonster.questTc) {
|
||||
return currentMonster.questTc[currentDifficulty];
|
||||
}
|
||||
return currentMonster.tc[currentDifficulty];
|
||||
return getMonsterPresetTc(currentMonster, currentDifficulty, isQuestDrop, customTcOverride);
|
||||
}
|
||||
|
||||
function getEffectivePlayers(): number {
|
||||
|
|
@ -1316,6 +1330,7 @@ export function initLootUi(): void {
|
|||
const id = el.getAttribute('data-mon-id');
|
||||
const m = MONSTER_PRESETS.find(x => x.id === id);
|
||||
if (m) {
|
||||
customTcOverride = null;
|
||||
currentMonster = m;
|
||||
if (tcInput) tcInput.value = getEffectiveTcName();
|
||||
renderMonsterList();
|
||||
|
|
@ -1347,7 +1362,11 @@ export function initLootUi(): void {
|
|||
}
|
||||
if (monsterCardArea) monsterCardArea.textContent = currentMonster.areaCn;
|
||||
if (monsterCardMlvl) monsterCardMlvl.textContent = String(mlvl);
|
||||
if (monsterCardPicks) monsterCardPicks.textContent = `${currentMonster.picks} 次`;
|
||||
if (monsterCardPicks) {
|
||||
const node = tcTable.get(tc);
|
||||
const picks = node ? Math.abs(node.picks) : currentMonster.picks;
|
||||
monsterCardPicks.textContent = `${picks} 次`;
|
||||
}
|
||||
if (monsterCardNodrop) {
|
||||
const g = parseInt(gamePlayersInput?.value || '1', 10) || 1;
|
||||
const p = parseInt(partyPlayersInput?.value || '1', 10) || 1;
|
||||
|
|
@ -1358,12 +1377,17 @@ export function initLootUi(): void {
|
|||
const scaledNoDrop = computeScaledNoDrop(node.noDrop, totalProb, eff);
|
||||
const singleNoDrop = scaledNoDrop / (scaledNoDrop + totalProb);
|
||||
const compoundNoDrop = node.picks > 0 ? Math.pow(singleNoDrop, node.picks) : 0;
|
||||
monsterCardNodrop.textContent = `${(compoundNoDrop * 100).toFixed(2)}%`;
|
||||
monsterCardNodrop.textContent = `${(singleNoDrop * 100).toFixed(1)}%`;
|
||||
monsterCardNodrop.title = `单次 NoDrop: ${(singleNoDrop * 100).toFixed(2)}%, 全空概率: ${(compoundNoDrop * 100).toFixed(2)}%`;
|
||||
} else {
|
||||
monsterCardNodrop.textContent = '0.00%';
|
||||
monsterCardNodrop.title = 'NoDrop 为 0 (必定掉落)';
|
||||
}
|
||||
}
|
||||
if (monsterCardTc) monsterCardTc.textContent = tc;
|
||||
if (monsterCardTc) {
|
||||
monsterCardTc.textContent = tc;
|
||||
monsterCardTc.title = customTcOverride ? `自定义 TC: ${tc}` : `标准 TC: ${tc}`;
|
||||
}
|
||||
if (monsterCardDesc) monsterCardDesc.textContent = currentMonster.descCn;
|
||||
|
||||
if (questDropToggle) {
|
||||
|
|
@ -1383,7 +1407,7 @@ export function initLootUi(): void {
|
|||
effectivePlayersHint.textContent = `当前有效掉落人数: ${eff} 人 (NoDrop 缩减已生效)`;
|
||||
}
|
||||
|
||||
if (statTargetName) statTargetName.textContent = currentMonster.nameCn;
|
||||
if (statTargetName) statTargetName.textContent = customTcOverride ? `${currentMonster.nameCn} (自定义TC)` : currentMonster.nameCn;
|
||||
if (statCurrentTc) statCurrentTc.textContent = tc;
|
||||
if (statEffPlayers) statEffPlayers.textContent = `${getEffectivePlayers()} P`;
|
||||
}
|
||||
|
|
@ -1392,6 +1416,7 @@ export function initLootUi(): void {
|
|||
|
||||
function setDifficulty(diff: 'normal' | 'nightmare' | 'hell') {
|
||||
currentDifficulty = diff;
|
||||
customTcOverride = null;
|
||||
diffButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-diff') === diff);
|
||||
});
|
||||
|
|
@ -1435,6 +1460,7 @@ export function initLootUi(): void {
|
|||
// Quest Drop toggle
|
||||
function setQuestDrop(val: boolean) {
|
||||
isQuestDrop = val;
|
||||
customTcOverride = null;
|
||||
if (questDropToggle) questDropToggle.checked = val;
|
||||
if (questDropCheckbox) questDropCheckbox.checked = val;
|
||||
if (tcInput) tcInput.value = getEffectiveTcName();
|
||||
|
|
@ -1451,17 +1477,31 @@ export function initLootUi(): void {
|
|||
|
||||
// Custom TC input
|
||||
btnApplyCustomTc?.addEventListener('click', () => {
|
||||
if (tcInput?.value.trim()) {
|
||||
const val = tcInput?.value.trim();
|
||||
if (val) {
|
||||
customTcOverride = val;
|
||||
} else {
|
||||
customTcOverride = null;
|
||||
if (tcInput) tcInput.value = getEffectiveTcName();
|
||||
}
|
||||
updateMonsterSpecCard();
|
||||
updateExactTable();
|
||||
});
|
||||
|
||||
presetSelect?.addEventListener('change', () => {
|
||||
const val = presetSelect.value;
|
||||
if (val) {
|
||||
customTcOverride = val;
|
||||
if (tcInput) tcInput.value = val;
|
||||
updateMonsterSpecCard();
|
||||
updateExactTable();
|
||||
}
|
||||
});
|
||||
|
||||
presetSelect?.addEventListener('change', () => {
|
||||
const val = presetSelect.value;
|
||||
if (val && tcInput) {
|
||||
tcInput.value = val;
|
||||
updateExactTable();
|
||||
tcInput?.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
btnApplyCustomTc?.click();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
runMonteCarloSimulation,
|
||||
loadCanonicalTreasureClasses,
|
||||
CANONICAL_ITEM_NAMES,
|
||||
MONSTER_PRESETS,
|
||||
getMonsterPresetTc,
|
||||
type ReverseLookupEntry,
|
||||
} from '../src/loot.ts'
|
||||
|
||||
|
|
@ -328,4 +330,83 @@ describe('Loot Drop Simulator & Exact Probability Table (Issue #110)', () => {
|
|||
expect(tcNames).toContain('Act 1 H2H A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. Monster Preset TC Resolution & Accuracy (Issue #121)', () => {
|
||||
it('verifies that all MONSTER_PRESETS define valid TCs that exist in tcTable', () => {
|
||||
expect(MONSTER_PRESETS.length).toBeGreaterThanOrEqual(15)
|
||||
|
||||
for (const m of MONSTER_PRESETS) {
|
||||
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
||||
const tcName = m.tc[diff]
|
||||
expect(tcName).toBeDefined()
|
||||
expect(tcName.length).toBeGreaterThan(0)
|
||||
const node = tcTable.get(tcName) ?? autoTcs.get(tcName)
|
||||
expect(node, `Monster ${m.id} difficulty ${diff} TC "${tcName}" must exist in tcTable`).toBeDefined()
|
||||
|
||||
if (m.questTc) {
|
||||
const qtcName = m.questTc[diff]
|
||||
expect(qtcName).toBeDefined()
|
||||
const qnode = tcTable.get(qtcName) ?? autoTcs.get(qtcName)
|
||||
expect(qnode, `Monster ${m.id} quest TC "${qtcName}" must exist in tcTable`).toBeDefined()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves Travincal Council (Council) to Council TCs, not Baal (H) (Issue #121 bug reproduction)', () => {
|
||||
const council = MONSTER_PRESETS.find(m => m.id === 'council')!
|
||||
expect(council).toBeDefined()
|
||||
|
||||
// In Nightmare difficulty, Council must resolve to 'Council (N)', NOT 'Baal (H)'
|
||||
const tcNightmare = getMonsterPresetTc(council, 'nightmare', false)
|
||||
expect(tcNightmare).toBe('Council (N)')
|
||||
expect(tcNightmare).not.toBe('Baal (H)')
|
||||
|
||||
// In Hell difficulty, Council must resolve to 'Council (H)', NOT 'Baal (H)'
|
||||
const tcHell = getMonsterPresetTc(council, 'hell', false)
|
||||
expect(tcHell).toBe('Council (H)')
|
||||
expect(tcHell).not.toBe('Baal (H)')
|
||||
|
||||
// In Normal difficulty, Council must resolve to 'Council'
|
||||
const tcNormal = getMonsterPresetTc(council, 'normal', false)
|
||||
expect(tcNormal).toBe('Council')
|
||||
})
|
||||
|
||||
it('resolves distinct TCs for every monster preset without leaking Baal (H)', () => {
|
||||
for (const m of MONSTER_PRESETS) {
|
||||
if (m.id === 'baal') continue
|
||||
|
||||
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
|
||||
const tc = getMonsterPresetTc(m, diff, false)
|
||||
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (H)`).not.toBe('Baal (H)')
|
||||
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal`).not.toBe('Baal')
|
||||
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (N)`).not.toBe('Baal (N)')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves quest TCs correctly when quest drop is enabled', () => {
|
||||
const andariel = MONSTER_PRESETS.find(m => m.id === 'andariel')!
|
||||
expect(getMonsterPresetTc(andariel, 'hell', true)).toBe('Andarielq (H)')
|
||||
expect(getMonsterPresetTc(andariel, 'nightmare', true)).toBe('Andarielq (N)')
|
||||
expect(getMonsterPresetTc(andariel, 'normal', true)).toBe('Andarielq')
|
||||
|
||||
const mephisto = MONSTER_PRESETS.find(m => m.id === 'mephisto')!
|
||||
expect(getMonsterPresetTc(mephisto, 'hell', true)).toBe('Mephistoq (H)')
|
||||
|
||||
const baal = MONSTER_PRESETS.find(m => m.id === 'baal')!
|
||||
expect(getMonsterPresetTc(baal, 'hell', true)).toBe('Baalq (H)')
|
||||
})
|
||||
|
||||
it('respects customOverride when explicitly provided, but monster selection clears it', () => {
|
||||
const council = MONSTER_PRESETS.find(m => m.id === 'council')!
|
||||
|
||||
// When custom override is active
|
||||
expect(getMonsterPresetTc(council, 'nightmare', false, 'Runes 17')).toBe('Runes 17')
|
||||
|
||||
// When custom override is empty or null, returns monster TC
|
||||
expect(getMonsterPresetTc(council, 'nightmare', false, null)).toBe('Council (N)')
|
||||
expect(getMonsterPresetTc(council, 'nightmare', false, '')).toBe('Council (N)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue