fix(i18n): resolve all Iteration 1 bilingual parity findings for Issue #503

- Prefer item.damageEn in ENG item tooltips and normalize 2H, versatile 1H/2H, and throwing weapon damage labels in localizeStarterTooltipLine and item-bridge.ts
- Fix starter set item tooltip regex ordering (+所有技能 without trailing $ and +生命补给 before +生命)
- Use getLocalizedNpcName for Town NPC stone menu headers in both ENG and CHI modes across getNpcMenuLayout, handleNpcMenuClick, and drawNpcMenu
- Add SUPERUNIQUE_TBL_ALIASES in monsterDisplayName for 7 SuperUniques whose SuperUniques.txt IDs differ from .tbl keys
- Export cleanChiItemName in ground-labels.ts and apply to CHI ground item labels and drop-to-ground status messages
- Update verify-issue-503-browser.ts LeftDockPanelKind loop + vendor/NPC coverage and expand issue-503-bilingual-parity.test.ts

Refs #503
This commit is contained in:
troytt 2026-09-28 06:30:24 +00:00
parent 6fa9c7784e
commit ef8a7ffff6
7 changed files with 310 additions and 33 deletions

View File

@ -279,8 +279,8 @@ async function main(): Promise<void> {
};
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<void> {
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');

View File

@ -959,6 +959,18 @@ export function formatEliteAffixLabel(modNameOrLabel: string, lang: TblLang = ge
export const affixDisplayName = formatEliteAffixLabel
const SUPERUNIQUE_TBL_ALIASES: Readonly<
Record<string, { readonly tblKey?: string; readonly zh: string; readonly en: string }>
> = {
'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'
}

View File

@ -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

View File

@ -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 '物品'
}

View File

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

View File

@ -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

View File

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