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:
parent
6fa9c7784e
commit
ef8a7ffff6
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '物品'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue