diff --git a/scripts/verify-issue-503-browser.ts b/scripts/verify-issue-503-browser.ts index 2757478..e42b3a4 100644 --- a/scripts/verify-issue-503-browser.ts +++ b/scripts/verify-issue-503-browser.ts @@ -279,8 +279,8 @@ async function main(): Promise { }; try { - // Exercise Quest Log, Waypoint, Merchant, Hireling, Stash, Cube, Skill Tree, Character, Inventory - for (const left of ['quest', 'wp', 'npc', 'hire', 'stash', 'cube', 'char']) { + // Exercise Quest Log, Waypoint, Stash, Cube, Character, Skill Tree, Inventory, NPC Menu, and Vendor Tabs + for (const left of ['quest', 'waypoint', 'stash', 'cube', 'char']) { if (inst.leftPanel !== left) inst.toggleLeftPanel(left); inst.render(performance.now(), false); } @@ -288,6 +288,19 @@ async function main(): Promise { if (inst.rightPanel !== right) inst.toggleRightPanel(right); inst.render(performance.now(), false); } + inst.openNpcMenuByName('Akara', 400, 220); + inst.render(performance.now(), false); + inst.worldPanels.npcMenu = null; + inst.openVendorSession('Charsi', 'trade', 0); + for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc']) { + inst.worldPanels.selectVendorTab(tabId); + const placements = inst.worldPanels.vendorTabPlacements[tabId] || []; + for (const p of placements) { + inst.worldPanels.hoveredVendorItem = { item: p.item, x: 200, y: 200, col: p.col, row: p.row }; + inst.render(performance.now(), false); + } + } + inst.worldPanels.hoveredVendorItem = null; // Leave char + inv open with helm tooltip hovered for screenshot if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char'); if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv'); diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 1db4797..012307b 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -959,6 +959,18 @@ export function formatEliteAffixLabel(modNameOrLabel: string, lang: TblLang = ge export const affixDisplayName = formatEliteAffixLabel +const SUPERUNIQUE_TBL_ALIASES: Readonly< + Record +> = { + 'Blood Raven': { tblKey: 'Bloodraven', zh: '血鳥', en: 'Blood Raven' }, + 'Creeping Feature': { tblKey: 'The Feature Creep', zh: '蠕蟲特徵', en: 'Creeping Feature' }, + 'Sszark the Burning': { zh: '火焰之河斯扎克', en: 'Sszark the Burning' }, + 'Battlemaid Sarina': { tblKey: 'Sarina the Battlemaid', zh: '戰場處女沙莉娜', en: 'Battlemaid Sarina' }, + 'Hephasto the Armorer': { tblKey: 'Hephasto The Armorer', zh: '盔甲製造者海法斯特', en: 'Hephasto The Armorer' }, + 'Eyeback the Unleashed': { tblKey: 'Eyeback Unleashed', zh: '狂暴者艾巴克', en: 'Eyeback the Unleashed' }, + 'Thresh Socket': { zh: '剝殼凹槽', en: 'Thresh Socket' }, +} + /** * Resolves a monster's localized display name from `.tbl` (`MonStats.txt` `NameStr` / `SuperUniques.txt` `Name`). */ @@ -970,6 +982,14 @@ export function monsterDisplayName( const trimmed = statsOrName.trim() const fromTbl = lookupTbl(trimmed, lang) if (fromTbl) return fromTbl + const alias = SUPERUNIQUE_TBL_ALIASES[trimmed] + if (alias) { + if (alias.tblKey) { + const fromAlias = lookupTbl(alias.tblKey, lang) + if (fromAlias) return fromAlias + } + return lang === 'ENG' ? alias.en : alias.zh + } const strippedNum = trimmed.replace(/\d+$/, '') if (strippedNum && strippedNum !== trimmed) { const fromStripped = lookupTbl(strippedNum, lang) @@ -987,8 +1007,17 @@ export function monsterDisplayName( statsOrName.id?.replace(/\d+$/, ''), ].filter((c): c is string => Boolean(c && c.trim().length > 0)) for (const key of candidates) { - const val = lookupTbl(key.trim(), lang) + const trimmed = key.trim() + const val = lookupTbl(trimmed, lang) if (val) return val + const alias = SUPERUNIQUE_TBL_ALIASES[trimmed] + if (alias) { + if (alias.tblKey) { + const fromAlias = lookupTbl(alias.tblKey, lang) + if (fromAlias) return fromAlias + } + return lang === 'ENG' ? alias.en : alias.zh + } } return statsOrName.name || statsOrName.id || 'Monster' } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f1f4f7c..d45191c 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -67,6 +67,7 @@ import { computeInitialGroundLabelLayouts, resolveLadderCollisions, isGroundItemEtherealOrSocketed, + cleanChiItemName, } from '../ui/ground-labels.ts' import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts' import { @@ -8494,7 +8495,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st const isEn = getTblLang() === 'ENG' status.textContent = isEn ? `Dropped "${groundItem.name}" on ground` - : `已将「${groundItem.nameZh}」丢弃在地面` + : `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面` }, onDropGoldToGround: (amount) => { const player = engine.world.player diff --git a/src/ui/ground-labels.ts b/src/ui/ground-labels.ts index 8514674..1084069 100644 --- a/src/ui/ground-labels.ts +++ b/src/ui/ground-labels.ts @@ -111,6 +111,19 @@ export function getGroundItemQualityColor(item: GroundItemEntity): string { return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal! } +/** + * Strips mixed English parentheticals from Chinese item names in CHI mode + * while preserving authentic Chinese annotations like `(5/5 火升)`. + */ +export function cleanChiItemName(nameZh: string): string { + return nameZh + .replace(/^精神\s*\(Spirit\)\s*统盾$/, '精神统盾') + .replace(/\s*\(([A-Za-z]+)\s+符文\)$/, '符文') + .replace(/\s*\(Rainbow Facet\s*-\s*([^)]+)\)$/, ' ($1)') + .replace(/\s*\([A-Za-z0-9'+\-% /×·,.#:]+\)/g, '') + .trim() +} + /** * Format the user-facing display text for a ground item label. */ @@ -138,7 +151,7 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string { return baseNameEn } if (item.nameZh && item.name === baseNameEn) { - return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '') + return cleanChiItemName(item.nameZh) } const translatedBase = translateItemName(rawBaseName) !== rawBaseName @@ -151,11 +164,11 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string { return item.name || src?.name || src?.item?.name || 'Item' } if (item.nameZh) { - return item.nameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '') + return cleanChiItemName(item.nameZh) } const srcNameZh = src?.nameZh ?? src?.item?.nameZh if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') { - return srcNameZh.replace(/\s*\([A-Za-z0-9' -]+\)$/, '') + return cleanChiItemName(srcNameZh) } if (item.name) { const q = (item.quality || 'normal').toLowerCase() @@ -169,7 +182,7 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string { if (src?.base || src?.code || src?.item?.base || src?.item?.code) { return translateItemName(item.name) } - return item.name.replace(/\s*\([A-Za-z0-9' -]+\)$/, '') + return cleanChiItemName(item.name) } return '物品' } diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index ad93fe2..420cfe0 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -525,13 +525,16 @@ export function itemToUiInventoryItem( if (!isGambleMystery) { if (tt.oneHandDamage && tt.twoHandDamage) { damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` - damageEn = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max} / Two-Hand: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}` + damageEn = `One-Hand Damage: ${tt.oneHandDamage.min} to ${tt.oneHandDamage.max} / Two-Hand Damage: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}` } else if (tt.twoHandDamage) { damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` damageEn = `Two-Hand Damage: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}` } else if (tt.oneHandDamage) { damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}` damageEn = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max}` + } else if ((base as any).minMisDam && (base as any).maxMisDam) { + damage = `投掷伤害: ${(base as any).minMisDam} - ${(base as any).maxMisDam}` + damageEn = `Throw Damage: ${(base as any).minMisDam} to ${(base as any).maxMisDam}` } else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) { damage = String((base as any).damage) damageEn = String((base as any).damage) diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 6ef8ec5..7dc2c3c 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -1841,10 +1841,9 @@ export class WorldPanelsHud { { id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }, { id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' }, ] - const headerName = - this.lang === 'ENG' - ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG')) - : this.npcMenu.npcName + const headerName = this.npcMenu.descriptor + ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) + : getLocalizedNpcName(this.npcMenu.npcName, this.lang) return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang) } @@ -1906,10 +1905,9 @@ export class WorldPanelsHud { { id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }, { id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' }, ] - const headerName = - this.lang === 'ENG' - ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG')) - : this.npcMenu.npcName + const headerName = this.npcMenu.descriptor + ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) + : getLocalizedNpcName(this.npcMenu.npcName, this.lang) const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang) if ( @@ -2695,10 +2693,9 @@ export class WorldPanelsHud { return } - const headerName = - this.lang === 'ENG' - ? (this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, 'ENG') : getLocalizedNpcName(this.npcMenu.npcName, 'ENG')) - : this.npcMenu.npcName + const headerName = this.npcMenu.descriptor + ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) + : getLocalizedNpcName(this.npcMenu.npcName, this.lang) const opts = this.npcMenu.options ?? [ { id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }, { id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' }, @@ -3025,13 +3022,15 @@ export class WorldPanelsHud { font: 'font8', }) } - if (item.damage !== undefined) { + if (item.damage !== undefined || item.damageEn !== undefined) { if (isEn) { - const cleanDmg = localizeStarterTooltipLine(item.damage, true) + const rawDmg = item.damageEn ?? item.damage ?? '' + const cleanDmg = localizeStarterTooltipLine(rawDmg, true) const dmgText = /Damage/i.test(cleanDmg) ? cleanDmg : `One-Hand Damage: ${cleanDmg}` lines.push({ text: dmgText, color: 'white', font: 'font8' }) } else { - const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage) + const rawDmg = item.damage ?? '' + const dmgText = rawDmg.includes('伤害') ? rawDmg : ('单手伤害: ' + rawDmg) lines.push({ text: dmgText, color: 'white', font: 'font8' }) } } @@ -3691,7 +3690,12 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { } // ENG mode: zero CJK characters allowed - if (!/[\u4e00-\u9fff]/.test(text)) return text + if (!/[\u4e00-\u9fff]/.test(text)) { + return text.replace( + /^(\d+)\s*to\s*(\d+)\s*\/\s*Two-Hand:\s*(\d+)\s*to\s*(\d+)$/i, + 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4', + ) + } if (STARTER_STAT_EN_EXACT[text]) return STARTER_STAT_EN_EXACT[text]! // If the string has an explicit English parenthetical like `谐角之冠 (Harlequin Crest)` or `+2 所有技能 (+2 To All Skills)` @@ -3724,13 +3728,23 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { .replace(/\s*\(限德鲁伊\)/g, ' (Druid Only)') .replace(/\s*\(限刺客\)/g, ' (Assassin Only)') .replace(/\s*\(限亚马逊\)/g, ' (Amazon Only)') + // Weapon damage lines + .replace(/^双手伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Two-Hand Damage: $1 to $2') + .replace(/^投掷伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Throw Damage: $1 to $2') + .replace(/^单手伤害:\s*(\d+)\s*-\s*(\d+)$/, 'One-Hand Damage: $1 to $2') + .replace(/^(\d+)\s*-\s*(\d+)\s*\/\s*双手:\s*(\d+)\s*-\s*(\d+)$/, 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4') + .replace(/^(\d+)\s*to\s*(\d+)\s*\/\s*Two-Hand:\s*(\d+)\s*to\s*(\d+)$/i, 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4') + .replace(/^双手伤害:\s*/, 'Two-Hand Damage: ') + .replace(/^投掷伤害:\s*/, 'Throw Damage: ') + .replace(/^单手伤害:\s*/, 'One-Hand Damage: ') + .replace(/\s*\/\s*双手:\s*/, ' / Two-Hand Damage: ') // Specific base/title patterns .replace(/^法术书\s*\(数量[::]\s*(\d+)\)$/, 'Tome (Quantity: $1)') .replace(/^符文\s*\(#(\d+)\)$/, 'Rune (#$1)') .replace(/^带插槽\s*\((\d+)\)$/, 'Socketed ($1)') .replace(/^凹槽\s*\((\d+)\)$/, 'Socketed ($1)') // Stat patterns - .replace(/^\+(\d+)\s*所有技能$/, '+$1 to All Skills') + .replace(/^\+(\d+)\s*所有技能/, '+$1 to All Skills') .replace(/^\+(\d+)\s*亚马逊技能等级/, '+$1 to Amazon Skill Levels') .replace(/^\+(\d+)\s*法师技能等级/, '+$1 to Sorceress Skill Levels') .replace(/^\+(\d+)\s*死灵法师技能等级/, '+$1 to Necromancer Skill Levels') @@ -3802,8 +3816,10 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { .replace(/^增加\s*(\d+)\s*冰冷伤害/, 'Adds $1 Cold Damage') .replace(/^增加\s*(\d+-\d+)\s*魔法伤害/, 'Adds $1 Magic Damage') .replace(/^增加\s*(\d+-\d+)\s*伤害/, 'Adds $1 Damage') - .replace(/^\+([\d.]+)\s*生命/, '+$1 to Life') + .replace(/^\+(\d+)\s*生命补给/, 'Replenish Life +$1') + .replace(/^生命回复\s*\+(\d+)/, 'Replenish Life +$1') .replace(/^\+([\d.]+)\s*最大生命/, '+$1 to Maximum Life') + .replace(/^\+([\d.]+)\s*生命/, '+$1 to Life') .replace(/^\+([\d.]+)\s*法力/, '+$1 to Mana') .replace(/^\+(\d+)\s*最大耐力/, '+$1 Maximum Stamina') .replace(/^\+(\d+)\s*力量/, '+$1 to Strength') @@ -3817,8 +3833,6 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { .replace(/^\+(\d+)\s*照亮范围/, '+$1 to Light Radius') .replace(/^\+(\d+)\s*每次击杀获得法力/, '+$1 to Mana After Each Kill') .replace(/^\+(\d+)\s*魔法吸收/, '+$1 Magic Absorb') - .replace(/^\+(\d+)\s*生命补给/, 'Replenish Life +$1') - .replace(/^生命回复\s*\+(\d+)/, 'Replenish Life +$1') .replace(/^攻击者受到伤害\s*(\d+)/, 'Attacker Takes Damage of $1') return s diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index ab28a0f..aedeeec 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -26,19 +26,27 @@ import { import { ACT_NAMES_EN, ACT_NAMES_ZH, + LEVEL_NAMES_ZH, levelDisplayName, sceneDisplayName, variantDisplayLabel, } from '../src/game/level-names-zh.ts' -import { formatEliteAffixLabel, monsterDisplayName } from '../src/game/monsters.ts' -import { getLocalizedNpcName } from '../src/game/npc-table.ts' +import { + CANONICAL_SUPER_UNIQUE_LANDMARKS, + formatEliteAffixLabel, + monsterDisplayName, +} from '../src/game/monsters.ts' +import { TOWN_NPC_DESCRIPTORS, getLocalizedNpcName } from '../src/game/npc-table.ts' +import { createStarterProfileForClass } from '../src/game/class-starter-profiles.ts' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { CharacterSheetPanel } from '../src/ui/character-sheet.ts' -import { InventoryPanel } from '../src/ui/inventory.ts' +import { InventoryPanel, itemToUiInventoryItem } from '../src/ui/inventory.ts' import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts' -import { WorldPanelsHud } from '../src/ui/world-panels.ts' +import { ACT_QUESTS, ACT_WAYPOINTS, WorldPanelsHud } from '../src/ui/world-panels.ts' import { ControlBarHud } from '../src/ui/control-bar.ts' import { GlobesHud } from '../src/ui/globes.ts' import { SkillHotkeysHud } from '../src/ui/hotkeys.ts' +import { cleanChiItemName, formatGroundItemLabelText } from '../src/ui/ground-labels.ts' import type { D2FontRenderer } from '../src/ui/font.ts' const CJK_REGEX = /[\u4e00-\u9fff]/ @@ -462,4 +470,200 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () expect(textCalls).not.toContain('私人储物箱 (Personal Stash)') expect(textCalls).not.toContain('赫拉迪克方块 (Horadric Cube)') }) + + it('localizes all 136 levels, 27 quests, and 39 waypoints cleanly in both ENG and CHI', () => { + const levelKeys = Object.keys(LEVEL_NAMES_ZH) + expect(levelKeys.length).toBeGreaterThanOrEqual(136) + for (let levelId = 1; levelId <= 136; levelId++) { + const zhName = LEVEL_NAMES_ZH[levelId]! + expect(zhName).toBeTruthy() + setTblLang('ENG') + const resolvedEn = levelDisplayName(levelId) + expect(resolvedEn.length).toBeGreaterThan(0) + expect(CJK_REGEX.test(resolvedEn)).toBe(false) + + setTblLang('CHI') + const resolvedZh = levelDisplayName(levelId) + expect(resolvedZh).toBe(zhName) + expect(CJK_REGEX.test(resolvedZh)).toBe(true) + } + + const wpHud = new WorldPanelsHud() + let totalQuests = 0 + for (const act of [1, 2, 3, 4, 5] as const) { + const quests = ACT_QUESTS[act] ?? [] + for (const q of quests) { + totalQuests++ + for (const status of ['completed', 'active', 'locked'] as const) { + wpHud.setQuestStatus(q.id, status) + const enText = wpHud.getQuestDisplayText(q, 'ENG') + expect(CJK_REGEX.test(enText.title)).toBe(false) + expect(CJK_REGEX.test(enText.body)).toBe(false) + expect(CJK_REGEX.test(enText.statusTag)).toBe(false) + if (enText.rewardText) expect(CJK_REGEX.test(enText.rewardText)).toBe(false) + + const zhText = wpHud.getQuestDisplayText(q, 'CHI') + expect(CJK_REGEX.test(zhText.title)).toBe(true) + expect(CJK_REGEX.test(zhText.body)).toBe(true) + expect(CJK_REGEX.test(zhText.statusTag)).toBe(true) + } + } + } + expect(totalQuests).toBe(27) + + let totalWaypoints = 0 + for (const act of [1, 2, 3, 4, 5] as const) { + const wps = ACT_WAYPOINTS[act] ?? [] + for (const wp of wps) { + totalWaypoints++ + expect(CJK_REGEX.test(wp.nameEn)).toBe(false) + expect(CJK_REGEX.test(wp.nameZh)).toBe(true) + } + } + expect(totalWaypoints).toBe(39) + }) + + it('renders pure Chinese header (no English parentheticals) in CHI mode and pure English header in ENG mode for all Town NPCs', () => { + expect(TOWN_NPC_DESCRIPTORS.length).toBeGreaterThanOrEqual(30) + const dummyBox = { complete: true, naturalWidth: 192, naturalHeight: 16 } as any + for (const desc of TOWN_NPC_DESCRIPTORS) { + const { ctx: ctxChi, font: fontChi, textCalls: callsChi } = createMockCanvasAndFont() + const wpChi = new WorldPanelsHud() + wpChi.lang = 'CHI' + wpChi.openNpcMenu(desc, 400, 220) + wpChi.drawNpcMenu(ctxChi, fontChi, { boxPiecesImg: dummyBox }) + expect(callsChi.length).toBeGreaterThan(0) + const headerChi = callsChi[0]! + expect(CJK_REGEX.test(headerChi)).toBe(true) + expect(/\([A-Za-z]/.test(headerChi)).toBe(false) + + const { ctx: ctxEng, font: fontEng, textCalls: callsEng } = createMockCanvasAndFont() + const wpEng = new WorldPanelsHud() + wpEng.lang = 'ENG' + wpEng.openNpcMenu(desc, 400, 220) + wpEng.drawNpcMenu(ctxEng, fontEng, { boxPiecesImg: dummyBox }) + expect(callsEng.length).toBeGreaterThan(0) + for (const line of callsEng) { + expect(CJK_REGEX.test(line)).toBe(false) + } + } + }) + + it('renders zero CJK in ENG tooltips and zero mixed parentheticals in CHI ground labels across all 7 class starter profiles', () => { + const classCodes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const + expect(classCodes.length).toBe(7) + + for (const code of classCodes) { + const profile = createStarterProfileForClass(code) + const allItems = [ + ...Object.values(profile.equipped).filter(Boolean), + ...profile.bagItems.map(b => b.item), + ] + for (const item of allItems) { + if (!item) continue + // 1. ENG Tooltip check + const { ctx, font, textCalls } = createMockCanvasAndFont() + const wp = new WorldPanelsHud() + wp.lang = 'ENG' + wp.drawItemTooltip(ctx, { item, x: 400, y: 300 }, font, false) + const cjkLines = textCalls.filter(line => CJK_REGEX.test(line)) + expect(cjkLines, `Starter item ${item.id} (${code}) leaked CJK in ENG tooltip: ${cjkLines.join(' | ')}`).toEqual([]) + + // 2. CHI Ground Label & Drop Notification check + setTblLang('CHI') + const groundEntity = { + id: 1, + name: item.name, + nameZh: item.nameZh, + quality: item.quality, + x: 0, + y: 0, + item, + isGold: false, + amount: 1, + droppedAt: 0, + } as any + const labelChi = formatGroundItemLabelText(groundEntity) + expect(/\([A-Za-z]/.test(labelChi), `Ground label in CHI leaked English parens: ${labelChi}`).toBe(false) + const cleanedDropName = cleanChiItemName(item.nameZh) + expect(/\([A-Za-z]/.test(cleanedDropName), `cleanChiItemName leaked English parens: ${cleanedDropName}`).toBe(false) + } + } + }) + + it('renders zero CJK and proper One-Hand/Two-Hand/Throw Damage labels in ENG tooltips across all 659 base items and vendor weapons', () => { + setTblLang('ENG') + const tables = getEmbeddedDropTables() + const allBases = [ + ...tables.weapons.byCode.values(), + ...tables.armor.byCode.values(), + ...tables.misc.byCode.values(), + ] + expect(allBases.length).toBeGreaterThanOrEqual(650) + + for (const base of allBases) { + const raw: any = { + id: `test-${base.id}`, + code: base.id, + name: base.name, + nameZh: (base as any).nameZh, + quality: 'normal', + identified: true, + level: 1, + base, + invWidth: base.invWidth ?? 1, + invHeight: base.invHeight ?? 1, + durability: (base as any).durability ?? 10, + maxDurability: (base as any).durability ?? 10, + defense: (base as any).defense ?? 0, + stats: {}, + affixes: [], + rolledProps: [], + runewordProps: [], + } + const uiItem = itemToUiInventoryItem(raw, tables) + const { ctx, font, textCalls } = createMockCanvasAndFont() + const wp = new WorldPanelsHud() + wp.lang = 'ENG' + wp.drawItemTooltip(ctx, { item: uiItem, x: 400, y: 300 }, font, true) + for (const line of textCalls) { + expect(CJK_REGEX.test(line), `Base item ${base.id} (${base.name}) leaked CJK in ENG: ${line}`).toBe(false) + expect(line).not.toMatch(/One-Hand Damage:\s*One-Hand Damage:/i) + expect(line).not.toMatch(/\/\s*Two-Hand:/i) + } + } + + // Also test Charsi vendor inventory across all 4 tabs + const wpVendor = new WorldPanelsHud() + wpVendor.lang = 'ENG' + wpVendor.openVendorForNpc('Charsi', 'trade', 85, 0) + for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc'] as const) { + for (const p of wpVendor.vendorTabPlacements[tabId]) { + const { ctx, font, textCalls } = createMockCanvasAndFont() + wpVendor.drawItemTooltip(ctx, { item: p.item, x: 400, y: 300 }, font, true) + for (const line of textCalls) { + expect(CJK_REGEX.test(line), `Charsi ${tabId} item ${p.item.name} leaked CJK in ENG: ${line}`).toBe(false) + expect(line).not.toMatch(/\/\s*Two-Hand:/i) + } + } + } + }) + + it('resolves all 23 SuperUnique landmarks in CANONICAL_SUPER_UNIQUE_LANDMARKS to Chinese in CHI mode and English in ENG mode', () => { + expect(CANONICAL_SUPER_UNIQUE_LANDMARKS.length).toBe(23) + for (const su of CANONICAL_SUPER_UNIQUE_LANDMARKS) { + const zhName = monsterDisplayName( + { id: su.minionMonsterId || su.id, name: su.id, superUniqueId: su.id }, + 'CHI', + ) + expect(CJK_REGEX.test(zhName), `SuperUnique ${su.id} did not resolve to Chinese in CHI mode: ${zhName}`).toBe(true) + + const enName = monsterDisplayName( + { id: su.minionMonsterId || su.id, name: su.id, superUniqueId: su.id }, + 'ENG', + ) + expect(CJK_REGEX.test(enName), `SuperUnique ${su.id} leaked CJK in ENG mode: ${enName}`).toBe(false) + } + }) }) +