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:
troytt 2026-09-19 03:58:27 +00:00
parent 9b4347a72f
commit c9a9a5558d
2 changed files with 138 additions and 17 deletions

View File

@ -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();
}
});

View File

@ -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)')
})
})
})