425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import { detectInitialLang, type GameLang } from '../i18n/lang.ts'
|
||
|
||
export const SETTINGS_STORAGE_KEY = 'd2web.settings.v1'
|
||
export const DEFAULT_SERVER_HOST = 'www.laiseek.xyz'
|
||
|
||
export type ClientLightingPreset =
|
||
| 'auto'
|
||
| 'noon'
|
||
| 'dusk'
|
||
| 'night'
|
||
| 'torch'
|
||
| 'cold'
|
||
| 'fullbright'
|
||
|
||
export type AutomapRevealMode = 'off' | 'level' | 'act'
|
||
export type ViewportResolution = '800x600' | '1024x768' | '1068x600' | '1280x720'
|
||
|
||
export interface ClientSettings {
|
||
readonly version: 1
|
||
readonly serverHost: string // default: 'www.laiseek.xyz'
|
||
readonly lang: 'zh' | 'en' // default: detected from URL/browser or 'zh'
|
||
readonly lightingPreset: ClientLightingPreset
|
||
readonly automapReveal: AutomapRevealMode
|
||
readonly viewport: ViewportResolution
|
||
readonly inspectorOpen: boolean
|
||
readonly audioMuted: boolean
|
||
readonly masterVolume: number // 0.0 .. 1.0
|
||
}
|
||
|
||
export const VALID_LIGHTING_PRESETS: readonly ClientLightingPreset[] = [
|
||
'auto',
|
||
'noon',
|
||
'dusk',
|
||
'night',
|
||
'torch',
|
||
'cold',
|
||
'fullbright',
|
||
] as const
|
||
|
||
export const VALID_AUTOMAP_REVEAL_MODES: readonly AutomapRevealMode[] = [
|
||
'off',
|
||
'level',
|
||
'act',
|
||
] as const
|
||
|
||
export const VALID_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
|
||
'800x600',
|
||
'1024x768',
|
||
'1068x600',
|
||
'1280x720',
|
||
] as const
|
||
|
||
export function createDefaultClientSettings(defaultLang?: GameLang): ClientSettings {
|
||
return {
|
||
version: 1,
|
||
serverHost: DEFAULT_SERVER_HOST,
|
||
lang: defaultLang ?? detectInitialLang(),
|
||
lightingPreset: 'auto',
|
||
automapReveal: 'off',
|
||
viewport: '800x600',
|
||
inspectorOpen: false,
|
||
audioMuted: false,
|
||
masterVolume: 0.8,
|
||
}
|
||
}
|
||
|
||
export const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
|
||
version: 1,
|
||
serverHost: DEFAULT_SERVER_HOST,
|
||
lang: 'zh',
|
||
lightingPreset: 'auto',
|
||
automapReveal: 'off',
|
||
viewport: '800x600',
|
||
inspectorOpen: false,
|
||
audioMuted: false,
|
||
masterVolume: 0.8,
|
||
}
|
||
|
||
export type ServerHostValidationErrorReason =
|
||
| 'empty'
|
||
| 'bare_ip_on_https'
|
||
| 'invalid_format'
|
||
| 'invalid_port'
|
||
|
||
export interface ServerHostValidationResult {
|
||
readonly valid: boolean
|
||
readonly normalizedHost: string
|
||
readonly reason?: ServerHostValidationErrorReason | undefined
|
||
readonly errorMessage?: string | undefined
|
||
readonly errorMessageZh?: string | undefined
|
||
}
|
||
|
||
const IPV4_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||
const HOSTNAME_LABEL_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||
|
||
export function isBareIpv4(hostWithoutPort: string): boolean {
|
||
const m = IPV4_REGEX.exec(hostWithoutPort)
|
||
if (!m) return false
|
||
for (let i = 1; i <= 4; i++) {
|
||
const octet = Number(m[i])
|
||
if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
export function isBareIpv6(hostWithoutPort: string): boolean {
|
||
const unwrapped =
|
||
hostWithoutPort.startsWith('[') && hostWithoutPort.endsWith(']')
|
||
? hostWithoutPort.slice(1, -1)
|
||
: hostWithoutPort
|
||
if (!unwrapped.includes(':')) return false
|
||
return /^[0-9a-fA-F:]+$/.test(unwrapped)
|
||
}
|
||
|
||
export function isBareIpAddress(hostWithoutPort: string): boolean {
|
||
return isBareIpv4(hostWithoutPort) || isBareIpv6(hostWithoutPort)
|
||
}
|
||
|
||
/**
|
||
* Validate and normalize a user-supplied `serverHost` value.
|
||
* Rejects bare IPv4/IPv6 addresses when the page is served over `https:` because
|
||
* `wss://<ip>/ws/*` fails browser TLS certificate SAN verification.
|
||
*/
|
||
export function validateServerHost(
|
||
rawHost: string,
|
||
pageProtocol: string = typeof window !== 'undefined' && window.location
|
||
? window.location.protocol
|
||
: 'https:',
|
||
): ServerHostValidationResult {
|
||
if (typeof rawHost !== 'string') {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'empty',
|
||
errorMessage: 'Server host must not be empty.',
|
||
errorMessageZh: '服务器地址不能为空。',
|
||
}
|
||
}
|
||
|
||
let cleaned = rawHost.trim()
|
||
if (cleaned === '') {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'empty',
|
||
errorMessage: 'Server host must not be empty.',
|
||
errorMessageZh: '服务器地址不能为空。',
|
||
}
|
||
}
|
||
|
||
// Strip optional scheme prefix if user pasted full URL
|
||
cleaned = cleaned.replace(/^(?:wss?|https?):\/\//i, '')
|
||
// Strip any path/query/hash suffix
|
||
const slashIdx = cleaned.search(/[/?#]/)
|
||
if (slashIdx >= 0) {
|
||
cleaned = cleaned.slice(0, slashIdx)
|
||
}
|
||
cleaned = cleaned.trim()
|
||
|
||
if (cleaned === '' || /\s/.test(cleaned)) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_format',
|
||
errorMessage: 'Invalid server hostname format.',
|
||
errorMessageZh: '服务器域名格式无效。',
|
||
}
|
||
}
|
||
|
||
let hostPart = cleaned
|
||
let portPart: string | undefined
|
||
|
||
if (cleaned.startsWith('[')) {
|
||
const closeBracket = cleaned.indexOf(']')
|
||
if (closeBracket < 0) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_format',
|
||
errorMessage: 'Invalid IPv6 bracket syntax.',
|
||
errorMessageZh: 'IPv6 地址括号格式无效。',
|
||
}
|
||
}
|
||
hostPart = cleaned.slice(0, closeBracket + 1)
|
||
const rest = cleaned.slice(closeBracket + 1)
|
||
if (rest.startsWith(':')) {
|
||
portPart = rest.slice(1)
|
||
} else if (rest !== '') {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_format',
|
||
errorMessage: 'Unexpected characters after IPv6 address.',
|
||
errorMessageZh: 'IPv6 地址后包含无效字符。',
|
||
}
|
||
}
|
||
} else {
|
||
const firstColon = cleaned.indexOf(':')
|
||
const lastColon = cleaned.lastIndexOf(':')
|
||
if (firstColon !== -1 && firstColon === lastColon) {
|
||
hostPart = cleaned.slice(0, firstColon)
|
||
portPart = cleaned.slice(firstColon + 1)
|
||
} else if (firstColon !== lastColon) {
|
||
// Unbracketed IPv6 literal
|
||
hostPart = cleaned
|
||
}
|
||
}
|
||
|
||
if (portPart !== undefined) {
|
||
if (!/^\d+$/.test(portPart)) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_port',
|
||
errorMessage: `Invalid port "${portPart}": must be an integer between 1 and 65535.`,
|
||
errorMessageZh: `端口 "${portPart}" 无效:必须为 1 到 65535 之间的整数。`,
|
||
}
|
||
}
|
||
const portNum = Number(portPart)
|
||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_port',
|
||
errorMessage: `Port ${portPart} is out of range (1..65535).`,
|
||
errorMessageZh: `端口 ${portPart} 超出有效范围 (1..65535)。`,
|
||
}
|
||
}
|
||
}
|
||
|
||
const isIp = isBareIpAddress(hostPart)
|
||
const normProto = (pageProtocol || 'https:').trim().toLowerCase()
|
||
const isHttps = normProto === 'https:' || normProto === 'https' || normProto === 'wss:' || normProto === 'wss'
|
||
|
||
if (isIp && isHttps) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'bare_ip_on_https',
|
||
errorMessage:
|
||
`Cannot connect to bare IP "${hostPart}" from an HTTPS page: WSS TLS requires a domain name with a valid certificate (use "${DEFAULT_SERVER_HOST}").`,
|
||
errorMessageZh:
|
||
`HTTPS 页面下无法对裸 IP "${hostPart}" 发起安全 WSS 连接:TLS 证书校验要求使用有效域名(请使用 "${DEFAULT_SERVER_HOST}")。`,
|
||
}
|
||
}
|
||
|
||
if (!isIp) {
|
||
const labels = hostPart.split('.')
|
||
if (labels.some((label) => label.length === 0 || !HOSTNAME_LABEL_REGEX.test(label))) {
|
||
return {
|
||
valid: false,
|
||
normalizedHost: '',
|
||
reason: 'invalid_format',
|
||
errorMessage: `Invalid hostname "${hostPart}". Expected a valid domain such as ${DEFAULT_SERVER_HOST}.`,
|
||
errorMessageZh: `域名 "${hostPart}" 格式无效,请输入有效域名(如 ${DEFAULT_SERVER_HOST})。`,
|
||
}
|
||
}
|
||
}
|
||
|
||
const normalizedHostPart = hostPart.toLowerCase()
|
||
const normalizedHost =
|
||
portPart !== undefined ? `${normalizedHostPart}:${portPart}` : normalizedHostPart
|
||
|
||
return {
|
||
valid: true,
|
||
normalizedHost,
|
||
}
|
||
}
|
||
|
||
export function normalizeClientLightingPreset(value: unknown): ClientLightingPreset {
|
||
if (typeof value !== 'string') return 'auto'
|
||
const clean = value.trim().toLowerCase()
|
||
if ((VALID_LIGHTING_PRESETS as readonly string[]).includes(clean)) {
|
||
return clean as ClientLightingPreset
|
||
}
|
||
if (clean === 'sunset' || clean === 'morning') return 'dusk'
|
||
if (clean === 'cave' || clean === 'hell') return 'torch'
|
||
if (clean === 'eclipse') return 'cold'
|
||
if (clean === 'off' || clean === 'none' || clean === 'false' || clean === '0') return 'fullbright'
|
||
return 'auto'
|
||
}
|
||
|
||
export function normalizeAutomapReveal(value: unknown): AutomapRevealMode {
|
||
if (typeof value !== 'string') return 'off'
|
||
const clean = value.trim().toLowerCase()
|
||
if ((VALID_AUTOMAP_REVEAL_MODES as readonly string[]).includes(clean)) {
|
||
return clean as AutomapRevealMode
|
||
}
|
||
return 'off'
|
||
}
|
||
|
||
export function normalizeViewportResolution(value: unknown): ViewportResolution {
|
||
if (typeof value !== 'string') return '800x600'
|
||
const clean = value.trim().toLowerCase()
|
||
for (const id of VALID_VIEWPORT_RESOLUTIONS) {
|
||
// Canonical `WxH` id, or the bare width shorthand (`'1280'`): registered widths are unique.
|
||
if (clean === id || clean === id.slice(0, id.indexOf('x'))) return id
|
||
}
|
||
return '800x600'
|
||
}
|
||
|
||
export function normalizeMasterVolume(value: unknown, fallback = 0.8): number {
|
||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||
return fallback
|
||
}
|
||
return Math.max(0, Math.min(1, value))
|
||
}
|
||
|
||
/**
|
||
* Sanitize an arbitrary input value into a strictly valid `ClientSettings` object.
|
||
* Never throws; strips unknown fields and replaces corrupt fields with defaults.
|
||
*/
|
||
export function sanitizeClientSettings(
|
||
raw: unknown,
|
||
options?: {
|
||
readonly defaultLang?: GameLang | undefined
|
||
readonly pageProtocol?: string | undefined
|
||
},
|
||
): ClientSettings {
|
||
const defaults = createDefaultClientSettings(options?.defaultLang)
|
||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||
return defaults
|
||
}
|
||
|
||
const rec = raw as Record<string, unknown>
|
||
|
||
let serverHost = defaults.serverHost
|
||
if (typeof rec.serverHost === 'string') {
|
||
const check = validateServerHost(rec.serverHost, options?.pageProtocol ?? 'https:')
|
||
if (check.valid) {
|
||
serverHost = check.normalizedHost
|
||
}
|
||
}
|
||
|
||
let lang: GameLang = defaults.lang
|
||
if (rec.lang === 'en' || rec.lang === 'zh') {
|
||
lang = rec.lang
|
||
} else if (typeof rec.lang === 'string') {
|
||
const norm = rec.lang.trim().toLowerCase()
|
||
if (norm === 'eng' || norm === 'english') lang = 'en'
|
||
else if (norm === 'chi' || norm === 'zh-cn' || norm === 'cn') lang = 'zh'
|
||
}
|
||
|
||
const lightingPreset =
|
||
rec.lightingPreset !== undefined
|
||
? normalizeClientLightingPreset(rec.lightingPreset)
|
||
: defaults.lightingPreset
|
||
|
||
const automapReveal =
|
||
rec.automapReveal !== undefined
|
||
? normalizeAutomapReveal(rec.automapReveal)
|
||
: defaults.automapReveal
|
||
|
||
const viewport =
|
||
rec.viewport !== undefined
|
||
? normalizeViewportResolution(rec.viewport)
|
||
: defaults.viewport
|
||
|
||
const inspectorOpen =
|
||
typeof rec.inspectorOpen === 'boolean' ? rec.inspectorOpen : defaults.inspectorOpen
|
||
|
||
const audioMuted =
|
||
typeof rec.audioMuted === 'boolean' ? rec.audioMuted : defaults.audioMuted
|
||
|
||
const masterVolume = normalizeMasterVolume(rec.masterVolume, defaults.masterVolume)
|
||
|
||
return {
|
||
version: 1,
|
||
serverHost,
|
||
lang,
|
||
lightingPreset,
|
||
automapReveal,
|
||
viewport,
|
||
inspectorOpen,
|
||
audioMuted,
|
||
masterVolume,
|
||
}
|
||
}
|
||
|
||
export function parseClientSettingsJson(
|
||
jsonStr: string | null | undefined,
|
||
options?: {
|
||
readonly defaultLang?: GameLang | undefined
|
||
readonly pageProtocol?: string | undefined
|
||
},
|
||
): ClientSettings {
|
||
if (typeof jsonStr !== 'string' || jsonStr.trim() === '') {
|
||
return createDefaultClientSettings(options?.defaultLang)
|
||
}
|
||
try {
|
||
const parsed: unknown = JSON.parse(jsonStr)
|
||
return sanitizeClientSettings(parsed, options)
|
||
} catch {
|
||
return createDefaultClientSettings(options?.defaultLang)
|
||
}
|
||
}
|
||
|
||
export interface RealmWsEndpoints {
|
||
readonly bncsUrl: string
|
||
readonly mcpUrl: string
|
||
readonly d2gsUrl: string
|
||
}
|
||
|
||
/**
|
||
* Derive the WebSocket proxy endpoints (`wss://<host>/ws/{bncs,mcp,d2gs}`) from `serverHost`.
|
||
*/
|
||
export function deriveRealmWsEndpoints(
|
||
serverHost: string,
|
||
pageProtocol: string = typeof window !== 'undefined' && window.location
|
||
? window.location.protocol
|
||
: 'https:',
|
||
): RealmWsEndpoints {
|
||
const check = validateServerHost(serverHost, pageProtocol)
|
||
const host = check.valid ? check.normalizedHost : DEFAULT_SERVER_HOST
|
||
const normProto = (pageProtocol || 'https:').trim().toLowerCase()
|
||
const wsScheme = normProto === 'http:' || normProto === 'http' || normProto === 'ws:' ? 'ws' : 'wss'
|
||
return {
|
||
bncsUrl: `${wsScheme}://${host}/ws/bncs`,
|
||
mcpUrl: `${wsScheme}://${host}/ws/mcp`,
|
||
d2gsUrl: `${wsScheme}://${host}/ws/d2gs`,
|
||
}
|
||
}
|