From c9a9a5558dc9fb10c4c00141024d0aaeffba58ee Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 03:58:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(loot):=20=E4=BF=AE=E5=A4=8D=E6=80=AA?= =?UTF-8?q?=E7=89=A9=E9=80=89=E6=8B=A9=E5=99=A8TC=E8=A2=ABBaal(H)=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E8=A6=86=E7=9B=96=E7=9A=84=E7=BC=BA=E9=99=B7=20(Issue?= =?UTF-8?q?=20#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 getEffectiveTcName 中因比对 tcInput.value 产生误判,导致所有选定怪物均错误回退到初始 Baal (H) 的核心 Bug - 引入清晰独立的 customTcOverride 状态变量,仅在用户显式提交高级自定义 TC 时生效 - 在选择新怪物、切换难度或切换任务首杀状态时自动重置 customTcOverride,保证当前怪物真实 1.13c TC 生效 - 优化怪物卡片 Picks 次数与单次 NoDrop 比例展示,提升数据可读性 - 新增 tests/loot.test.ts 专项测试验证 Council、Andariel、Pindleskin 等所有预设怪物 TC 解析正确性 --- src/loot.ts | 74 ++++++++++++++++++++++++++++++++---------- tests/loot.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/src/loot.ts b/src/loot.ts index dd4bc1a..730b65e 100644 --- a/src/loot.ts +++ b/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> = { @@ -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(); } }); diff --git a/tests/loot.test.ts b/tests/loot.test.ts index 71909ac..1024947 100644 --- a/tests/loot.test.ts +++ b/tests/loot.test.ts @@ -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)') + }) + }) })