Fix campfire bafws/pafws forward overlays, frontend manifest tbl strings, main.ts validation/difficulty guards, and i18n listener error propagation (Closes #702, #712)
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 fromPath = resolveLangFromPathname(u.pathname)
|
||||||
const path = u.pathname.toLowerCase()
|
if (fromPath !== null) {
|
||||||
if (
|
return fromPath === 'en' ? 'ENG' : 'CHI'
|
||||||
path.endsWith('/acts-en.html') ||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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']")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||