Fix campfire bafws/pafws forward overlays, frontend manifest tbl strings, main.ts validation/difficulty guards, and i18n listener error propagation (Closes #702, #712)

This commit is contained in:
troytt 2026-10-03 02:28:31 +00:00
parent 171f7c00fc
commit 0897bf6114
18 changed files with 2263 additions and 533 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@ export interface PackedAnimStripMeta {
readonly frameCount: number readonly frameCount: number
readonly rate?: number readonly rate?: number
readonly animRate?: number readonly animRate?: number
readonly drawMode?: number
readonly frames: readonly PackedStripFrameMeta[] readonly frames: readonly PackedStripFrameMeta[]
} }
@ -233,6 +234,7 @@ function packFramesToGrid(
options?: { options?: {
readonly rate?: number readonly rate?: number
readonly animRate?: number readonly animRate?: number
readonly drawMode?: number
}, },
): { ): {
meta: PackedAnimStripMeta meta: PackedAnimStripMeta
@ -275,6 +277,7 @@ function packFramesToGrid(
frameCount: frames.length, frameCount: frames.length,
...(options?.rate !== undefined ? { rate: options.rate } : {}), ...(options?.rate !== undefined ? { rate: options.rate } : {}),
...(options?.animRate !== undefined ? { animRate: options.animRate } : {}), ...(options?.animRate !== undefined ? { animRate: options.animRate } : {}),
...(options?.drawMode !== undefined ? { drawMode: options.drawMode } : {}),
frames: frameMetas, frames: frameMetas,
}, },
pixels, pixels,
@ -631,10 +634,13 @@ async function main(): Promise<void> {
animStrips['logoBnetFire'] = packedFire.meta animStrips['logoBnetFire'] = packedFire.meta
} }
// Campfire `fire.dc6` at control anchor (345, 454) on charactercreationscreenEXP.dc6 // Campfire `fire.dc6` at expansion control anchor (345, 470) on charactercreationscreenEXP.dc6 (0x6fa63080)
const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6') const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6')
{ {
const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048, { rate: 1 }) const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048, {
rate: 1,
drawMode: 3,
})
savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
animStrips['campfireFlame'] = packed.meta animStrips['campfireFlame'] = packed.meta
} }
@ -643,8 +649,9 @@ async function main(): Promise<void> {
// 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2) // 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2)
// ============================================================================ // ============================================================================
console.log('Packing 7-class campfire multi-state animations...') console.log('Packing 7-class campfire multi-state animations...')
// Per D2Launch.dll 0x6fa68df8, 0x6fa60c08 & 0x6fa5d780: // Per D2Launch.dll 0x6fa68df8, 0x6fa60c08, 0x6fa65d9c, 0x6fa65db0 & 0x6fa5d780:
// Note: ptAnimTable != NULL does NOT subtract frame0.offset! // Note: ptAnimTable != NULL does NOT subtract frame0.offset!
// DrawMode: 5 = DRAWMODE_NORMAL, 3 = DRAWMODE_MODULATE (screen)
interface ClassCampfireSpec { interface ClassCampfireSpec {
readonly id: string readonly id: string
readonly dir: string readonly dir: string
@ -654,7 +661,8 @@ async function main(): Promise<void> {
readonly base: string readonly base: string
readonly rate: number readonly rate: number
readonly overlay?: string readonly overlay?: string
readonly additiveOverlay?: boolean readonly overlayDrawMode?: 3 | 5
readonly compositeOverlay?: boolean
}[] }[]
} }
@ -667,7 +675,7 @@ async function main(): Promise<void> {
{ key: 'nu1', base: 'amnu1.dc6', rate: 3 }, { key: 'nu1', base: 'amnu1.dc6', rate: 3 },
{ key: 'nu2', base: 'amnu2.dc6', rate: 3 }, { key: 'nu2', base: 'amnu2.dc6', rate: 3 },
{ key: 'nu3', base: 'amnu3.dc6', rate: 2 }, { key: 'nu3', base: 'amnu3.dc6', rate: 2 },
{ key: 'fw', base: 'amfw.dc6', rate: 1, overlay: 'amfws.dc6', additiveOverlay: false }, { key: 'fw', base: 'amfw.dc6', rate: 1, overlay: 'amfws.dc6', compositeOverlay: true },
{ key: 'bw', base: 'ambw.dc6', rate: 1 }, { key: 'bw', base: 'ambw.dc6', rate: 1 },
], ],
}, },
@ -690,9 +698,9 @@ async function main(): Promise<void> {
states: [ states: [
{ key: 'nu1', base: 'nenu1.dc6', rate: 3 }, { key: 'nu1', base: 'nenu1.dc6', rate: 3 },
{ key: 'nu2', base: 'nenu2.dc6', rate: 3 }, { key: 'nu2', base: 'nenu2.dc6', rate: 3 },
{ key: 'nu3', base: 'nenu3.dc6', rate: 2, overlay: 'nenu3s.dc6', additiveOverlay: true }, { key: 'nu3', base: 'nenu3.dc6', rate: 2, overlay: 'nenu3s.dc6', overlayDrawMode: 3 },
{ key: 'fw', base: 'nefw.dc6', rate: 1, overlay: 'nefws.dc6', additiveOverlay: true }, { key: 'fw', base: 'nefw.dc6', rate: 1, overlay: 'nefws.dc6', overlayDrawMode: 3 },
{ key: 'bw', base: 'nebw.dc6', rate: 1, overlay: 'nebws.dc6', additiveOverlay: true }, { key: 'bw', base: 'nebw.dc6', rate: 1, overlay: 'nebws.dc6', overlayDrawMode: 3 },
], ],
}, },
{ {
@ -703,7 +711,7 @@ async function main(): Promise<void> {
{ key: 'nu1', base: 'banu1.dc6', rate: 2 }, { key: 'nu1', base: 'banu1.dc6', rate: 2 },
{ key: 'nu2', base: 'banu2.dc6', rate: 2 }, { key: 'nu2', base: 'banu2.dc6', rate: 2 },
{ key: 'nu3', base: 'banu3.dc6', rate: 1 }, { key: 'nu3', base: 'banu3.dc6', rate: 1 },
{ key: 'fw', base: 'bafw.dc6', rate: 1, overlay: 'bafws.dc6', additiveOverlay: false }, { key: 'fw', base: 'bafw.dc6', rate: 1, overlay: 'bafws.dc6', overlayDrawMode: 5 },
{ key: 'bw', base: 'babw.dc6', rate: 1 }, { key: 'bw', base: 'babw.dc6', rate: 1 },
], ],
}, },
@ -715,7 +723,7 @@ async function main(): Promise<void> {
{ key: 'nu1', base: 'panu1.dc6', rate: 2 }, { key: 'nu1', base: 'panu1.dc6', rate: 2 },
{ key: 'nu2', base: 'panu2.dc6', rate: 2 }, { key: 'nu2', base: 'panu2.dc6', rate: 2 },
{ key: 'nu3', base: 'panu3.dc6', rate: 2 }, { key: 'nu3', base: 'panu3.dc6', rate: 2 },
{ key: 'fw', base: 'pafw.dc6', rate: 1, overlay: 'pafws.dc6', additiveOverlay: false }, { key: 'fw', base: 'pafw.dc6', rate: 1, overlay: 'pafws.dc6', overlayDrawMode: 5 },
{ key: 'bw', base: 'pabw.dc6', rate: 1 }, { key: 'bw', base: 'pabw.dc6', rate: 1 },
], ],
}, },
@ -726,9 +734,9 @@ async function main(): Promise<void> {
states: [ states: [
{ key: 'nu1', base: 'sonu1.dc6', rate: 2 }, { key: 'nu1', base: 'sonu1.dc6', rate: 2 },
{ key: 'nu2', base: 'sonu2.dc6', rate: 2 }, { key: 'nu2', base: 'sonu2.dc6', rate: 2 },
{ key: 'nu3', base: 'sonu3.dc6', rate: 1, overlay: 'sonu3s.dc6', additiveOverlay: true }, { key: 'nu3', base: 'sonu3.dc6', rate: 1, overlay: 'sonu3s.dc6', overlayDrawMode: 3 },
{ key: 'fw', base: 'sofw.dc6', rate: 1, overlay: 'sofws.dc6', additiveOverlay: true }, { key: 'fw', base: 'sofw.dc6', rate: 1, overlay: 'sofws.dc6', overlayDrawMode: 3 },
{ key: 'bw', base: 'sobw.dc6', rate: 1, overlay: 'sobws.dc6', additiveOverlay: true }, { key: 'bw', base: 'sobw.dc6', rate: 1, overlay: 'sobws.dc6', overlayDrawMode: 3 },
], ],
}, },
{ {
@ -755,22 +763,38 @@ async function main(): Promise<void> {
? await decodeUiDc6(`data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.overlay}`) ? await decodeUiDc6(`data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.overlay}`)
: null : null
if (ovDc6 && !st.additiveOverlay) { if (ovDc6 && st.compositeOverlay) {
const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames) const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames)
const relFile = `campfire/${cls.id}-${st.key}.png` const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, composited, 2048, { rate: st.rate }) const packed = packFramesToGrid(relFile, composited, 2048, { rate: st.rate, drawMode: 5 })
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta campfireClasses[cls.id]![st.key] = packed.meta
} else { } else {
const relFile = `campfire/${cls.id}-${st.key}.png` const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048, { rate: st.rate }) const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048, {
rate: st.rate,
drawMode: 5,
})
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta campfireClasses[cls.id]![st.key] = packed.meta
if (ovDc6 && st.additiveOverlay) { if (ovDc6 && st.overlayDrawMode !== undefined) {
const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png` const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png`
const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048, { rate: st.rate }) const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048, {
rate: st.rate,
drawMode: st.overlayDrawMode,
})
savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true) savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true)
// Also write nested subdir path (`campfire/<id>/<phase>_ov.png`) for path compatibility
mkdirSync(join(outDir, 'campfire', cls.id), { recursive: true })
savePng(
`campfire/${cls.id}/${st.key}_ov.png`,
ovPacked.meta.width,
ovPacked.meta.height,
ovPacked.pixels,
fecharPl2.rgb,
true,
)
campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta
} }
} }
@ -920,12 +944,32 @@ async function main(): Promise<void> {
'BY CLICKING "AGREE" BELOW, YOU CONFIRM THAT YOU HAVE READ AND ACCEPTED THIS BATTLE.NET TERMS OF USE AGREEMENT IN ITS ENTIRETY.', 'BY CLICKING "AGREE" BELOW, YOU CONFIRM THAT YOU HAVE READ AND ACCEPTED THIS BATTLE.NET TERMS OF USE AGREEMENT IN ITS ENTIRETY.',
].join('\n') ].join('\n')
const s4007 = lookupById(4007) ?? 'Barbarian'
const s4008 = lookupById(4008) ?? 'Paladin'
const s4009 = lookupById(4009) ?? 'Necromancer'
const s4010 = lookupById(4010) ?? 'Sorceress'
const s4011 = lookupById(4011) ?? 'Amazon'
const s4012 = lookupById(4012) ?? 'Druid'
const s4013 = lookupById(4013) ?? 'Assassin'
const s4057 = lookupById(4057) ?? 'Level'
const s5017 = lookupById(5017) ?? 'Level %d'
const s5101 = lookupById(5101) ?? 'EXIT' const s5101 = lookupById(5101) ?? 'EXIT'
const s5102 = lookupById(5102) ?? 'OK' const s5102 = lookupById(5102) ?? 'OK'
const s5103 = lookupById(5103) ?? 'CANCEL' const s5103 = lookupById(5103) ?? 'CANCEL'
const s5171 = lookupById(5171) ?? 'Connecting to Battle.net'
const s5172 = lookupById(5172) ?? 'Querying gateway'
const s5173 = lookupById(5173) ?? 'Connecting to the fastest server'
const s5174 = lookupById(5174) ?? 'Accessing your account'
const s5175 = lookupById(5175) ?? 'Checking versions'
const s5176 = lookupById(5176) ?? 'Verifying CD-key'
const s5177 = lookupById(5177) ?? 'Getting player profile'
const s5180 = lookupById(5180) ?? 'Terms of Use' const s5180 = lookupById(5180) ?? 'Terms of Use'
const s5181 = lookupById(5181) ?? 'AGREE' const s5181 = lookupById(5181) ?? 'AGREE'
const s5182 = lookupById(5182) ?? 'DISAGREE' const s5182 = lookupById(5182) ?? 'DISAGREE'
const s5204 = lookupById(5204) ?? 'Battle.net Log In'
const s5205 =
lookupById(5205) ??
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'."
const s5216 = lookupById(5216) ?? 'Account name must be at least 2 characters.' const s5216 = lookupById(5216) ?? 'Account name must be at least 2 characters.'
const s5217 = lookupById(5217) ?? "Account name can't be more than 15 characters." const s5217 = lookupById(5217) ?? "Account name can't be more than 15 characters."
const s5218 = lookupById(5218) ?? "Passwords don't match." const s5218 = lookupById(5218) ?? "Passwords don't match."
@ -967,8 +1011,15 @@ async function main(): Promise<void> {
lookupById(5239) ?? lookupById(5239) ??
'An account already exists with the name you have chosen. Please try again with a different name.' 'An account already exists with the name you have chosen. Please try again with a different name.'
const s5249 = lookupById(5249) ?? 'Unable to Create Account' const s5249 = lookupById(5249) ?? 'Unable to Create Account'
const s5334 = lookupById(5334) ?? 'Gem Activated'
const s5335 = lookupById(5335) ?? 'Gem Deactivated'
const s5336 = lookupById(5336) ?? 'Perfect Gem Activated'
const s10834 = lookupById(10834) ?? 'Verify Password' const s10834 = lookupById(10834) ?? 'Verify Password'
const s11127 = lookupById(11127) ?? 'PLEASE WRITE DOWN YOUR PASSWORD.' const s11127 = lookupById(11127) ?? 'PLEASE WRITE DOWN YOUR PASSWORD.'
const s11132 = lookupById(11132) ?? 'Expires Today'
const s11133 = lookupById(11133) ?? 'Expires in %d days'
const s11134 = lookupById(11134) ?? 'Character Expired'
const s22731 = lookupById(22731) ?? 'EXPANSION CHARACTER'
const strings = { const strings = {
singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER', singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER',
@ -978,9 +1029,17 @@ async function main(): Promise<void> {
credits: 'CREDITS', credits: 'CREDITS',
cinematics: 'CINEMATICS', cinematics: 'CINEMATICS',
gatewayPrefix: 'GATEWAY: ', gatewayPrefix: 'GATEWAY: ',
connectingToFastest: 'Connecting to the fastest server...', connectingToBattleNet: s5171,
checkingVersions: 'Checking versions...', queryingGateway: s5172,
accessingAccount: 'Accessing your account...', connectingToFastest: s5173,
accessingAccount: s5174,
checkingVersions: s5175,
verifyingCdKey: s5176,
gettingPlayerProfile: s5177,
loginTitle: s5204,
bnetLoginTitle: s5204,
loginInstruction: s5205,
bnetLoginInstruction: s5205,
accountName: s5224, accountName: s5224,
password: s5225, password: s5225,
verifyPassword: s10834 || s5226, verifyPassword: s10834 || s5226,
@ -1016,12 +1075,38 @@ async function main(): Promise<void> {
errAccountAlreadyExists: s5239, errAccountAlreadyExists: s5239,
unableToCreateAccount: s5249, unableToCreateAccount: s5249,
termsOfUseText: defaultTermsOfUseText, termsOfUseText: defaultTermsOfUseText,
levelLabel: s4057,
levelFormat: s5017,
expiresToday: s11132,
expiresInDays: s11133,
characterExpired: s11134,
gemActivated: s5334,
gemDeactivated: s5335,
perfectGemActivated: s5336,
'4007': s4007,
'4008': s4008,
'4009': s4009,
'4010': s4010,
'4011': s4011,
'4012': s4012,
'4013': s4013,
'4057': s4057,
'5017': s5017,
'5101': s5101, '5101': s5101,
'5102': s5102, '5102': s5102,
'5103': s5103, '5103': s5103,
'5171': s5171,
'5172': s5172,
'5173': s5173,
'5174': s5174,
'5175': s5175,
'5176': s5176,
'5177': s5177,
'5180': s5180, '5180': s5180,
'5181': s5181, '5181': s5181,
'5182': s5182, '5182': s5182,
'5204': s5204,
'5205': s5205,
'5216': s5216, '5216': s5216,
'5217': s5217, '5217': s5217,
'5218': s5218, '5218': s5218,
@ -1047,57 +1132,63 @@ async function main(): Promise<void> {
'5238': s5238, '5238': s5238,
'5239': s5239, '5239': s5239,
'5249': s5249, '5249': s5249,
'5334': s5334,
'5335': s5335,
'5336': s5336,
'10834': s10834, '10834': s10834,
'11127': s11127, '11127': s11127,
'11132': s11132,
'11133': s11133,
'11134': s11134,
'22731': s22731,
chooseClassTitle: 'Choose a class for your campaign', chooseClassTitle: 'Choose a class for your campaign',
characterName: 'Character Name', characterName: 'Character Name',
expansionCharacter: 'EXPANSION CHARACTER', expansionCharacter: s22731,
hardcore: 'HARDCORE', hardcore: 'HARDCORE',
ladderCharacter: 'LADDER CHARACTER', ladderCharacter: 'LADDER CHARACTER',
selectCharacterTitle: 'Select a Character', selectCharacterTitle: 'Select a Character',
createNewCharacter: 'CREATE NEW\nCHARACTER', createNewCharacter: 'CREATE NEW\nCHARACTER',
convertToExpansion: 'CONVERT TO\nEXPANSION', convertToExpansion: 'CONVERT TO\nEXPANSION',
deleteCharacter: 'DELETE\nCHARACTER', deleteCharacter: 'DELETE\nCHARACTER',
expiresIn90Days: 'Expires in 90 days',
classes: { classes: {
am: { am: {
name: lookupByKey('Amazon') ?? 'Amazon', name: s4011,
desc: desc:
lookupByKey('strAmazonDesc') ?? lookupByKey('strAmazonDesc') ??
'Skilled with the spear and the bow, she is a very versatile fighter.', 'Skilled with the spear and the bow, she is a very versatile fighter.',
}, },
as: { as: {
name: lookupByKey('Assassin') ?? 'Assassin', name: s4013,
desc: desc:
lookupByKey('AssasinDesc') ?? lookupByKey('AssasinDesc') ??
'Schooled in the Martial Arts, her mind and body are deadly weapons.', 'Schooled in the Martial Arts, her mind and body are deadly weapons.',
}, },
ne: { ne: {
name: lookupByKey('Necromancer') ?? 'Necromancer', name: s4009,
desc: desc:
lookupByKey('strNecroDesc') ?? lookupByKey('strNecroDesc') ??
'Summoning undead minions and cursing his enemies are his specialties.', 'Summoning undead minions and cursing his enemies are his specialties.',
}, },
ba: { ba: {
name: lookupByKey('Barbarian') ?? 'Barbarian', name: s4007,
desc: desc:
lookupByKey('strBarbDesc') ?? lookupByKey('strBarbDesc') ??
'He is unequaled in close-quarters combat and mastery of weapons.', 'He is unequaled in close-quarters combat and mastery of weapons.',
}, },
pa: { pa: {
name: lookupByKey('Paladin') ?? 'Paladin', name: s4008,
desc: desc:
lookupByKey('strPaladinDesc') ?? lookupByKey('strPaladinDesc') ??
'He is a natural party leader, holy man, and blessed warrior.', 'He is a natural party leader, holy man, and blessed warrior.',
}, },
so: { so: {
name: lookupByKey('Sorceress') ?? 'Sorceress', name: s4010,
desc: desc:
lookupByKey('strSorcDesc') ?? lookupByKey('strSorcDesc') ??
'She has mastered the elemental magics -- fire, lightning, and ice.', 'She has mastered the elemental magics -- fire, lightning, and ice.',
}, },
dz: { dz: {
name: lookupByKey('Druid') ?? 'Druid', name: s4012,
desc: desc:
lookupByKey('DruidDesc') ?? lookupByKey('DruidDesc') ??
'Commanding the forces of nature, he summons wild beasts and raging storms to his side.', 'Commanding the forces of nature, he summons wild beasts and raging storms to his side.',
@ -1126,8 +1217,9 @@ async function main(): Promise<void> {
nightmare: 'Nightmare', nightmare: 'Nightmare',
hell: 'Hell', hell: 'Hell',
createGameBtn: 'CREATE GAME', createGameBtn: 'CREATE GAME',
gemActivated: 'Gem Activated', gemActivated: s5334,
gemDeactivated: 'Gem Deactivated', gemDeactivated: s5335,
perfectGemActivated: s5336,
}, },
} }

View File

@ -37,74 +37,90 @@ setDefaultTblLangProvider(
}, },
) )
/**
* Default language for entry pages without an explicit language suffix (`acts.html`, `arena.html`,
* `index.html`) or unrecognized paths, preserving backward compatibility with Chinese (`CHI` / `'zh'`)
* per Issue #503 & Issue #712.
*/
export const DEFAULT_GAME_LANG: GameLang = 'zh'
export const DEFAULT_TBL_LANG: TblLang = 'CHI'
/** Canonical HTML entry pages that resolve to English (`ENG` / `'en'`). */
export const ENGLISH_ENTRY_PAGES = [
'acts-en.html',
'arena-en.html',
'bnet.html',
'play-en.html',
] as const
/**
* Canonical HTML entry pages that resolve to Chinese (`CHI` / `'zh'`), including
* backward-compatible unsuffixed pages (`acts.html`, `arena.html`, `index.html`).
*/
export const CHINESE_ENTRY_PAGES = [
'acts-zh.html',
'arena-zh.html',
'acts.html',
'arena.html',
'index.html',
] as const
/**
* Resolve `GameLang` from a URL pathname if it matches a known HTML entry page:
* - `acts-en.html`, `arena-en.html`, `bnet.html`, `play-en.html` -> `'en'` (`ENG`)
* - `acts-zh.html`, `arena-zh.html`, `acts.html`, `arena.html`, `index.html` -> `'zh'` (`CHI`)
* - Any other pathname -> `null` (caller falls back to query param / localStorage / `<html lang>` / `DEFAULT_GAME_LANG`)
*/
export function resolveLangFromPathname(pathname: string): GameLang | null {
const path = (pathname || '').toLowerCase()
for (const page of ENGLISH_ENTRY_PAGES) {
if (path === page || path.endsWith(`/${page}`)) {
return 'en'
}
}
for (const page of CHINESE_ENTRY_PAGES) {
if (path === page || path.endsWith(`/${page}`)) {
return 'zh'
}
}
return null
}
/**
* Detect `TblLang` (`'CHI'` | `'ENG'`) from a URL string using deterministic precedence:
* 1. Explicit `?lang=` query parameter (`en`/`eng`/`english` -> `'ENG'`; `zh`/`chi`/`zh-cn`/`cn`/`chinese` -> `'CHI'`).
* 2. Known HTML entry-page filename via `resolveLangFromPathname` (`acts-en.html` -> `'ENG'`, `acts-zh.html` / `acts.html` -> `'CHI'`).
* 3. Backward-compatible default `DEFAULT_TBL_LANG` (`'CHI'`) when an explicit URL string is provided without a recognized language suffix.
* 4. Runtime `detectInitialLang()` when called with no URL argument.
*/
export function detectLangFromUrl(urlStr?: string): TblLang { export function detectLangFromUrl(urlStr?: string): TblLang {
if (typeof urlStr === 'string' && urlStr.trim() !== '') { if (typeof urlStr === 'string' && urlStr.trim() !== '') {
try {
const u = new URL(urlStr, 'http://localhost') const u = new URL(urlStr, 'http://localhost')
const q = u.searchParams.get('lang')?.trim().toLowerCase() const q = u.searchParams.get('lang')?.trim().toLowerCase()
if (q === 'en' || q === 'eng' || q === 'english') return 'ENG' if (q === 'en' || q === 'eng' || q === 'english') return 'ENG'
if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'CHI' if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'CHI'
const path = u.pathname.toLowerCase() const fromPath = resolveLangFromPathname(u.pathname)
if ( if (fromPath !== null) {
path.endsWith('/acts-en.html') || return fromPath === 'en' ? 'ENG' : 'CHI'
path.endsWith('acts-en.html') ||
path.endsWith('/arena-en.html') ||
path.endsWith('arena-en.html') ||
path.endsWith('/bnet.html') ||
path.endsWith('bnet.html') ||
path.endsWith('/play-en.html') ||
path.endsWith('play-en.html')
) {
return 'ENG'
}
if (
path.endsWith('/acts-zh.html') ||
path.endsWith('acts-zh.html') ||
path.endsWith('/arena-zh.html') ||
path.endsWith('arena-zh.html')
) {
return 'CHI'
}
return 'CHI'
} catch {
// fall through
} }
return DEFAULT_TBL_LANG
} }
return detectInitialLang() === 'en' ? 'ENG' : 'CHI' return detectInitialLang() === 'en' ? 'ENG' : 'CHI'
} }
export function detectInitialLang(): GameLang { export function detectInitialLang(): GameLang {
if (typeof window !== 'undefined' && window.location) { if (typeof window !== 'undefined' && window.location) {
try {
const params = new URLSearchParams(window.location.search || '') const params = new URLSearchParams(window.location.search || '')
const q = params.get('lang')?.trim().toLowerCase() const q = params.get('lang')?.trim().toLowerCase()
if (q === 'en' || q === 'eng' || q === 'english') return 'en' if (q === 'en' || q === 'eng' || q === 'english') return 'en'
if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'zh' if (q === 'zh' || q === 'chi' || q === 'zh-cn' || q === 'cn' || q === 'chinese') return 'zh'
} catch {
// Ignore malformed query string
}
const path = (window.location.pathname || '').toLowerCase() const path = (window.location.pathname || '').toLowerCase()
if ( const fromPath = resolveLangFromPathname(path)
path.endsWith('/acts-en.html') || if (fromPath !== null) {
path.endsWith('acts-en.html') || return fromPath
path.endsWith('/arena-en.html') ||
path.endsWith('arena-en.html') ||
path.endsWith('/bnet.html') ||
path.endsWith('bnet.html') ||
path.endsWith('/play-en.html') ||
path.endsWith('play-en.html')
) {
return 'en'
} }
if ( if (path === 'play.html' || path.endsWith('/play.html')) {
path.endsWith('/acts-zh.html') ||
path.endsWith('acts-zh.html') ||
path.endsWith('/arena-zh.html') ||
path.endsWith('arena-zh.html')
) {
return 'zh'
}
if (path.endsWith('/play.html') || path.endsWith('play.html')) {
try { try {
const raw = window.localStorage?.getItem('d2web.settings.v1') const raw = window.localStorage?.getItem('d2web.settings.v1')
if (raw) { if (raw) {
@ -114,7 +130,7 @@ export function detectInitialLang(): GameLang {
} }
} }
} catch { } catch {
// Ignore storage errors // Ignore storage access errors in sandboxed contexts
} }
} }
} }
@ -123,7 +139,7 @@ export function detectInitialLang(): GameLang {
if (docLang.startsWith('en')) return 'en' if (docLang.startsWith('en')) return 'en'
if (docLang.startsWith('zh')) return 'zh' if (docLang.startsWith('zh')) return 'zh'
} }
return 'zh' return DEFAULT_GAME_LANG
} }
export function getGameLang(): GameLang { export function getGameLang(): GameLang {
@ -154,20 +170,30 @@ function notifyListeners(): void {
const gameLang = getGameLang() const gameLang = getGameLang()
const tbl = gameLang === 'en' ? 'ENG' : 'CHI' const tbl = gameLang === 'en' ? 'ENG' : 'CHI'
setDefaultTblLang(tbl) setDefaultTblLang(tbl)
for (const fn of listeners) { const errors: unknown[] = []
for (const fn of [...listeners]) {
try { try {
fn(tbl) fn(tbl)
} catch { } catch (err) {
// ignore listener errors errors.push(err)
} }
} }
for (const fn of gameLangListeners) { for (const fn of [...gameLangListeners]) {
try { try {
fn(gameLang) fn(gameLang)
} catch { } catch (err) {
// ignore listener errors errors.push(err)
} }
} }
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(
errors,
`${errors.length} language change listeners failed during notifyListeners`,
)
}
} }
export function setGameLang(lang: GameLang | TblLang | null): void { export function setGameLang(lang: GameLang | TblLang | null): void {
@ -212,6 +238,8 @@ export function onLangChange(listener: (lang: GameLang) => void): () => void {
} }
} }
export const onGameLangChange = onLangChange
export function buildLangSwitchHref( export function buildLangSwitchHref(
targetLang?: TblLang | GameLang, targetLang?: TblLang | GameLang,
currentPath?: string, currentPath?: string,

View File

@ -472,29 +472,55 @@ async function bootPlayApp(): Promise<PlayAppController> {
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
} }
let bnetConnectPending = false
frontendRenderer.setAsyncHandlers({ frontendRenderer.setAsyncHandlers({
onConnect: async (st) => { onConnect: async (st) => {
st.statusText = '' const manifestStrings = frontendRenderer.manifest?.strings as
| Record<string, string | undefined>
| undefined
bnetConnectPending = true
;(st as { bnetConnectStage?: number }).bnetConnectStage = 0
st.statusText = manifestStrings?.['5171'] ?? manifestStrings?.connectingToBattleNet ?? ''
st.lastError = null st.lastError = null
st.loginError = null st.loginError = null
toolbar.setConnectionState({ phase: 'connecting', rttMs: null })
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
await Promise.resolve()
if (st.stage !== 'bnet_connecting') {
bnetConnectPending = false
return
}
;(st as { bnetConnectStage?: number }).bnetConnectStage = 2
st.statusText = manifestStrings?.['5173'] ?? manifestStrings?.connectingToFastest ?? ''
frontendRenderer.render(performance.now())
await Promise.resolve()
if (st.stage === 'bnet_connecting') {
bnetConnectPending = false
;(st as { bnetConnectStage?: number }).bnetConnectStage = 3
st.statusText = ''
st.stage = 'bnet_login'
st.stageTicks = 0
st.activeInput = st.accountName.trim().length > 0 ? 'accountPassword' : 'accountName'
frontendRenderer.render(performance.now())
}
}, },
onLogin: async (st, createNewAccount) => { onLogin: async (st, createNewAccount) => {
const username = st.accountName.trim() const username = st.accountName.trim()
const password = st.accountPassword const password = st.accountPassword
if (!username) { if (!username) {
st.loginError = 'PLEASE ENTER AN ACCOUNT NAME.'
st.lastError = st.loginError
frontendRenderer.render(performance.now())
return return
} }
if (loginInFlight) return if (loginInFlight) return
loginInFlight = true loginInFlight = true
st.statusText = '' const manifestStrings = frontendRenderer.manifest?.strings as
| Record<string, string | undefined>
| undefined
st.statusText = manifestStrings?.['5174'] ?? manifestStrings?.accessingAccount ?? ''
st.loginError = null st.loginError = null
st.lastError = null st.lastError = null
frontendRenderer.render(performance.now()) frontendRenderer.render(performance.now())
const targetHost = settingsStore.get().serverHost || st.gateway || 'www.laiseek.xyz' const targetHost = settingsStore.get().serverHost || 'www.laiseek.xyz'
try { try {
const res = createNewAccount const res = createNewAccount
? await session.createAccountAndLogin({ username, password, host: targetHost }) ? await session.createAccountAndLogin({ username, password, host: targetHost })
@ -507,10 +533,12 @@ async function bootPlayApp(): Promise<PlayAppController> {
if (res.realms[0]) { if (res.realms[0]) {
st.gateway = res.realms[0].title st.gateway = res.realms[0].title
} }
st.statusText = ''
applyOnlineCharactersToFlowState(st, res.characters, { applyOnlineCharactersToFlowState(st, res.characters, {
stayOnCharSelect: Boolean(createNewAccount), stayOnCharSelect: Boolean(createNewAccount),
}) })
} catch (err) { } catch (err) {
st.statusText = ''
if (err instanceof BncsCreateAccountError) { if (err instanceof BncsCreateAccountError) {
applyCreateAccountServerError( applyCreateAccountServerError(
st, st,
@ -529,10 +557,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
}, },
onCreateCharacter: async (st) => { onCreateCharacter: async (st) => {
const name = st.charName.trim() const name = st.charName.trim()
if (!name || name.length < 2 || !st.selectedClass) { if (!name || name.length < 2 || name.length > 15 || !st.selectedClass) {
st.charCreateError = 'CHARACTER NAME MUST BE 2-15 LETTERS.'
st.lastError = st.charCreateError
frontendRenderer.render(performance.now())
return return
} }
st.statusText = '' st.statusText = ''
@ -605,9 +630,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
resetHeroAvatarAnims(st) resetHeroAvatarAnims(st)
st.lobbySubPanel = 'create' st.lobbySubPanel = 'create'
st.createGameError = null st.createGameError = null
if (!isDifficultyUnlocked(selected, st.difficulty)) {
st.difficulty = 'normal'
}
st.activeInput = 'gameName' st.activeInput = 'gameName'
} catch (err) { } catch (err) {
if (err instanceof SessionLostError) return if (err instanceof SessionLostError) return
@ -645,32 +667,25 @@ async function bootPlayApp(): Promise<PlayAppController> {
onCreateGame: async (st) => { onCreateGame: async (st) => {
const gameName = st.gameName.trim() const gameName = st.gameName.trim()
if (!gameName) { if (!gameName) {
st.createGameError = 'PLEASE ENTER A GAME NAME.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return return
} }
const maxPlayers = Number.parseInt(st.maxPlayers.trim(), 10) const maxPlayers = Number.parseInt(st.maxPlayers.trim(), 10)
if (!Number.isFinite(maxPlayers) || maxPlayers < 1 || maxPlayers > 8) { if (!Number.isFinite(maxPlayers) || maxPlayers < 1 || maxPlayers > 8) {
st.createGameError = 'MAX NUMBER OF PLAYERS MUST BE 1-8.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return return
} }
let levelDiff = 0xff let levelDiff = 0xff
if (st.charDifferenceEnabled) { if (st.charDifferenceEnabled) {
const parsedDiff = Number.parseInt(st.charDifference.trim(), 10) const parsedDiff = Number.parseInt(st.charDifference.trim(), 10)
if (!Number.isFinite(parsedDiff) || parsedDiff < 1 || parsedDiff > 99) { if (!Number.isFinite(parsedDiff) || parsedDiff < 1 || parsedDiff > 99) {
st.createGameError = 'CHARACTER DIFFERENCE MUST BE 1-99.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return return
} }
levelDiff = parsedDiff levelDiff = parsedDiff
} }
const selectedChar = st.roster[st.selectedSlotIndex] const selectedChar = st.roster[st.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, st.difficulty)) { if (selectedChar && !isDifficultyUnlocked(selectedChar, st.difficulty)) {
st.difficulty = 'normal' throw new Error(
`Difficulty "${st.difficulty}" is not unlocked for character "${selectedChar.name}" (progression=${selectedChar.progression})`,
)
} }
const diffNum: 0 | 1 | 2 = const diffNum: 0 | 1 | 2 =
st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0 st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0
@ -930,6 +945,15 @@ async function bootPlayApp(): Promise<PlayAppController> {
if (!inGameView) { if (!inGameView) {
stepFlowFrame(flowState, nowMs) stepFlowFrame(flowState, nowMs)
if (bnetConnectPending && flowState.stage === 'bnet_connecting' && flowState.stageTicks >= 1) {
bnetConnectPending = false
;(flowState as { bnetConnectStage?: number }).bnetConnectStage = 3
flowState.statusText = ''
flowState.stage = 'bnet_login'
flowState.stageTicks = 0
flowState.activeInput =
flowState.accountName.trim().length > 0 ? 'accountPassword' : 'accountName'
}
if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) { if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) {
inGameView = true inGameView = true
flowState.tickAccumulatorMs = 0 flowState.tickAccumulatorMs = 0

View File

@ -0,0 +1,226 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
detectLangFromUrl,
onGameLangChange,
onLangChange,
resolveLangFromPathname,
setGameLang,
setLang,
} from '../../src/client/i18n/lang.ts'
const ROOT_DIR = resolve(import.meta.dirname, '../..')
const FRONTEND_DIR = resolve(ROOT_DIR, 'public/ui/frontend')
const MANIFEST_PATH = resolve(FRONTEND_DIR, 'manifest.json')
const MAIN_TS_PATH = resolve(ROOT_DIR, 'src/client/main.ts')
function readPngHeader(filePath: string): { readonly width: number; readonly height: number } {
const buf = readFileSync(filePath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
return {
width: buf.readUInt32BE(16),
height: buf.readUInt32BE(20),
}
}
describe('Batch 7 & 8 Phase 2 — Campfire Overlays, Manifest TBL Strings, main.ts Guards & i18n Fail-Fast (#702, #712, #697, #698, #699, #700, #704, #707, #708, #710)', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as {
readonly animStrips: Record<
string,
{
readonly file: string
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly drawMode?: number
}
>
readonly campfireClasses: Record<
string,
Record<
string,
{
readonly file: string
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly drawMode?: number
readonly frames: readonly unknown[]
}
>
>
readonly strings: Record<string, unknown> & {
readonly lobby: Record<string, unknown>
}
}
afterEach(() => {
setGameLang(null)
})
it('#712: lang.ts notifyListeners propagates listener errors instead of swallowing them', () => {
const unsub = onLangChange(() => {
throw new Error('broken tbl listener')
})
try {
expect(() => setLang('en')).toThrow('broken tbl listener')
} finally {
unsub()
}
const calls: string[] = []
const u1 = onGameLangChange((l) => {
calls.push(`first:${l}`)
throw new Error('first error')
})
const u2 = onGameLangChange((l) => {
calls.push(`second:${l}`)
})
try {
expect(() => setGameLang('zh')).toThrow('first error')
expect(calls).toEqual(['first:zh', 'second:zh'])
} finally {
u1()
u2()
}
expect(resolveLangFromPathname('/acts-en.html')).toBe('en')
expect(resolveLangFromPathname('/acts-zh.html')).toBe('zh')
expect(resolveLangFromPathname('/acts.html')).toBe('zh')
expect(detectLangFromUrl('http://localhost/acts-en.html')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-zh.html')).toBe('CHI')
expect(detectLangFromUrl('http://localhost/acts.html')).toBe('CHI')
})
it('#702: bakes Barbarian (bafws.dc6) and Paladin (pafws.dc6) forward overlays with drawMode=5 and ne/so overlays with drawMode=3', () => {
const baFwOv = manifest.campfireClasses.ba?.fw_ov
const paFwOv = manifest.campfireClasses.pa?.fw_ov
expect(baFwOv).toBeDefined()
expect(paFwOv).toBeDefined()
expect(baFwOv?.drawMode).toBe(5)
expect(paFwOv?.drawMode).toBe(5)
expect(baFwOv?.frameCount).toBe(manifest.campfireClasses.ba?.fw?.frameCount)
expect(paFwOv?.frameCount).toBe(manifest.campfireClasses.pa?.fw?.frameCount)
// Verify PNG files exist on disk under both flat (`campfire/ba-fw-ov.png`) and nested (`campfire/ba/fw_ov.png`) paths
for (const relPath of [
baFwOv!.file,
paFwOv!.file,
'campfire/ba/fw_ov.png',
'campfire/pa/fw_ov.png',
]) {
const fullPath = resolve(FRONTEND_DIR, relPath)
expect(existsSync(fullPath)).toBe(true)
const hdr = readPngHeader(fullPath)
expect(hdr.width).toBeGreaterThan(0)
expect(hdr.height).toBeGreaterThan(0)
}
const baPng = readPngHeader(resolve(FRONTEND_DIR, baFwOv!.file))
expect(baPng.width).toBe(baFwOv!.width)
expect(baPng.height).toBe(baFwOv!.height)
const paPng = readPngHeader(resolve(FRONTEND_DIR, paFwOv!.file))
expect(paPng.width).toBe(paFwOv!.width)
expect(paPng.height).toBe(paFwOv!.height)
// Necromancer and Sorceress additive overlays use drawMode 3 (DRAWMODE_MODULATE)
for (const clsId of ['ne', 'so'] as const) {
for (const ovKey of ['nu3_ov', 'fw_ov', 'bw_ov'] as const) {
const ov = manifest.campfireClasses[clsId]?.[ovKey]
expect(ov).toBeDefined()
expect(ov?.drawMode).toBe(3)
}
}
expect(manifest.animStrips.campfireFlame?.drawMode).toBe(3)
})
it('#698, #699, #700, #704, #708: manifest.json contains canonical .tbl strings without fake suffixes or hardcoded expiresIn90Days', () => {
const s = manifest.strings
// #698: Character expiration .tbl 11132, 11133, 11134; hardcoded expiresIn90Days removed
expect(s.expiresIn90Days).toBeUndefined()
expect(s['11132']).toBe('Expires Today')
expect(s['11133']).toBe('Expires in %d days')
expect(s['11134']).toBe('Character Expired')
expect(s.expiresToday).toBe('Expires Today')
expect(s.expiresInDays).toBe('Expires in %d days')
expect(s.characterExpired).toBe('Character Expired')
// #699: Canonical BNet connect status strings 5171..5177 without "..." suffixes
expect(s['5171']).toBe('Connecting to Battle.net')
expect(s['5172']).toBe('Querying gateway')
expect(s['5173']).toBe('Connecting to the fastest server')
expect(s['5174']).toBe('Accessing your account')
expect(s['5175']).toBe('Checking versions')
expect(s['5176']).toBe('Verifying CD-key')
expect(s['5177']).toBe('Getting player profile')
expect(s.connectingToFastest).toBe('Connecting to the fastest server')
expect(s.accessingAccount).toBe('Accessing your account')
expect(s.checkingVersions).toBe('Checking versions')
expect(String(s.connectingToFastest).endsWith('...')).toBe(false)
expect(String(s.accessingAccount).endsWith('...')).toBe(false)
expect(String(s.checkingVersions).endsWith('...')).toBe(false)
// #700: Login title (5204) and instruction (5205)
expect(s['5204']).toBe('Battle.net Log In')
expect(s['5205']).toBe(
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
)
expect(s.loginTitle).toBe(s['5204'])
expect(s.loginInstruction).toBe(s['5205'])
// #704: Chat gem .tbl 5334, 5335, 5336
expect(s['5334']).toBe('Gem Activated')
expect(s['5335']).toBe('Gem Deactivated')
expect(s['5336']).toBe('Perfect Gem Activated')
expect(s.lobby.gemActivated).toBe('Gem Activated')
expect(s.lobby.gemDeactivated).toBe('Gem Deactivated')
expect(s.lobby.perfectGemActivated).toBe('Perfect Gem Activated')
// #707 & #708: Create Account instruction (5223), Please Read paragraphs (5228..5230), Level & class names
expect(s['5223']).toBe('Enter the Name and Password for your new battle.net account.')
expect(s.createAccountInstruction).toBe(s['5223'])
expect(typeof s.pleaseReadPara1).toBe('string')
expect(typeof s.pleaseReadPara2).toBe('string')
expect(typeof s.pleaseReadPara3).toBe('string')
expect(s['4057']).toBe('Level')
expect(s['5017']).toBe('Level %d')
expect(s['4007']).toBe('Barbarian')
expect(s['4008']).toBe('Paladin')
expect(s['4009']).toBe('Necromancer')
expect(s['4010']).toBe('Sorceress')
expect(s['4011']).toBe('Amazon')
expect(s['4012']).toBe('Druid')
expect(s['4013']).toBe('Assassin')
})
it('#697, #699, #704, #707, #710: main.ts has zero invented English validation popups, zero st.gateway hostname fallback, and zero silent difficulty downgrade', () => {
const mainSrc = readFileSync(MAIN_TS_PATH, 'utf8')
// #697 & #707: Zero invented English validation error strings
expect(mainSrc).not.toContain('PLEASE ENTER AN ACCOUNT NAME.')
expect(mainSrc).not.toContain('CHARACTER NAME MUST BE 2-15 LETTERS.')
expect(mainSrc).not.toContain('PLEASE ENTER A GAME NAME.')
expect(mainSrc).not.toContain('MAX NUMBER OF PLAYERS MUST BE 1-8.')
expect(mainSrc).not.toContain('CHARACTER DIFFERENCE MUST BE 1-99.')
// #704: Never use display gateway name (`st.gateway`) as network hostname fallback
expect(mainSrc).not.toContain('|| st.gateway')
// #710: Never silently downgrade locked difficulty to 'normal'
expect(mainSrc).not.toContain("st.difficulty = 'normal'")
expect(mainSrc).toContain('is not unlocked for character')
// #699: onConnect wires canonical BNCS status stages (5171, 5173) into bnet_login
expect(mainSrc).toContain("manifestStrings?.['5171']")
expect(mainSrc).toContain("manifestStrings?.['5173']")
})
})

105
tests/lang.test.ts Normal file
View File

@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CHINESE_ENTRY_PAGES,
DEFAULT_GAME_LANG,
DEFAULT_TBL_LANG,
ENGLISH_ENTRY_PAGES,
detectInitialLang,
detectLangFromUrl,
getGameLang,
getTblLang,
onGameLangChange,
onLangChange,
onTblLangChange,
resolveLangFromPathname,
setGameLang,
setLang,
setTblLang,
} from '../src/client/i18n/lang.ts'
describe('i18n lang.ts — URL path resolution & fail-fast listener error propagation (Issue #712)', () => {
afterEach(() => {
setGameLang(null)
})
it('exports canonical entry page lists and resolves pathname deterministically', () => {
expect(DEFAULT_GAME_LANG).toBe('zh')
expect(DEFAULT_TBL_LANG).toBe('CHI')
expect(ENGLISH_ENTRY_PAGES).toContain('acts-en.html')
expect(ENGLISH_ENTRY_PAGES).toContain('bnet.html')
expect(CHINESE_ENTRY_PAGES).toContain('acts-zh.html')
expect(CHINESE_ENTRY_PAGES).toContain('acts.html')
expect(resolveLangFromPathname('/acts-en.html')).toBe('en')
expect(resolveLangFromPathname('/arena-en.html')).toBe('en')
expect(resolveLangFromPathname('/bnet.html')).toBe('en')
expect(resolveLangFromPathname('/play-en.html')).toBe('en')
expect(resolveLangFromPathname('/acts-zh.html')).toBe('zh')
expect(resolveLangFromPathname('/arena-zh.html')).toBe('zh')
expect(resolveLangFromPathname('/acts.html')).toBe('zh')
expect(resolveLangFromPathname('/arena.html')).toBe('zh')
expect(resolveLangFromPathname('/index.html')).toBe('zh')
expect(resolveLangFromPathname('/play.html')).toBeNull()
expect(resolveLangFromPathname('/unknown-route')).toBeNull()
})
it('resolves detectLangFromUrl across query params, entry pages, and backward-compatible default', () => {
expect(detectLangFromUrl('http://localhost/acts-en.html')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-zh.html')).toBe('CHI')
expect(detectLangFromUrl('http://localhost/acts.html')).toBe('CHI')
expect(detectLangFromUrl('http://localhost/acts.html?lang=en')).toBe('ENG')
expect(detectLangFromUrl('http://localhost/acts-en.html?lang=zh')).toBe('CHI')
expect(detectLangFromUrl('http://localhost/unrecognized.html')).toBe('CHI')
expect(detectInitialLang()).toBe('zh')
})
it('propagates a single listener error fail-fast instead of silently swallowing it', () => {
const unsub = onLangChange((lang) => {
throw new Error(`Font atlas reload failed for ${lang}`)
})
try {
expect(() => setLang('en')).toThrow('Font atlas reload failed for en')
expect(getGameLang()).toBe('en')
expect(getTblLang()).toBe('ENG')
} finally {
unsub()
}
})
it('notifies all remaining listeners and throws AggregateError when multiple listeners fail', () => {
const seen: string[] = []
const unsub1 = onTblLangChange((tbl) => {
seen.push(`tbl:${tbl}`)
throw new Error('First tbl listener failure')
})
const unsub2 = onGameLangChange((lang) => {
seen.push(`game:${lang}`)
throw new Error('Second gameLang listener failure')
})
const unsub3 = onGameLangChange((lang) => {
seen.push(`ok:${lang}`)
})
try {
let caught: unknown = null
try {
setTblLang('ENG')
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(AggregateError)
const agg = caught as AggregateError
expect(agg.errors).toHaveLength(2)
expect((agg.errors[0] as Error).message).toBe('First tbl listener failure')
expect((agg.errors[1] as Error).message).toBe('Second gameLang listener failure')
// All 3 listeners must have been invoked despite earlier listener throws
expect(seen).toEqual(['tbl:ENG', 'game:en', 'ok:en'])
} finally {
unsub1()
unsub2()
unsub3()
}
})
})