From 0ab4b61daba9c9c8e3ded76df968731b6b61bf80 Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Tue, 16 Jun 2026 05:32:15 +0000 Subject: [PATCH 1/6] Preact: Refactor connecting Adds a keepalive heartbeat, since WebSocket-raw doesn't have a heartbeat. Also improves the reconnect timer. --- .../src/client-connection-worker.ts | 60 +++++++++++- .../src/client-connection.ts | 91 ++++++++++++++----- play.pokemonshowdown.com/src/panel-chat.tsx | 15 +-- .../src/panel-mainmenu.tsx | 4 +- play.pokemonshowdown.com/src/panels.tsx | 16 ++++ 5 files changed, 149 insertions(+), 37 deletions(-) diff --git a/play.pokemonshowdown.com/src/client-connection-worker.ts b/play.pokemonshowdown.com/src/client-connection-worker.ts index 07c22a319..361b73047 100644 --- a/play.pokemonshowdown.com/src/client-connection-worker.ts +++ b/play.pokemonshowdown.com/src/client-connection-worker.ts @@ -1,15 +1,25 @@ declare const SockJS: any; import type { ServerInfo } from "./client-main"; +const KEEPALIVE_INTERVAL = 25000; +const KEEPALIVE_RANGE = 20000; +const RECONNECT_CAP = 60000; +const PING_RESPONSE = '|queryresponse|ping|'; + let socket: WebSocket | null = null; let serverInfo: ServerInfo; let reconnectTimeout: ReturnType | null = null; +let reconnectDelay = 1000; +let shouldReconnect = true; +let lastReceiveTime = Date.now(); let queue: string[] = []; self.onmessage = (event: MessageEvent) => { const { type, server, data } = event.data; if (type === 'connect') { serverInfo = server; + shouldReconnect = true; + reconnectDelay = 1000; connectToServer(); } else if (type === 'send') { if (socket?.readyState === WebSocket.OPEN) { @@ -18,14 +28,41 @@ self.onmessage = (event: MessageEvent) => { queue.push(data); } } else if (type === 'disconnect') { - if (socket) socket.close(); + shouldReconnect = false; if (reconnectTimeout) clearTimeout(reconnectTimeout); + reconnectTimeout = null; + if (socket) socket.close(); socket = null; } }; +/** + * Some internet connections will drop connections with zero activity. + * SockJS handles this by sending heartbeat pings, but since we're doing + * raw WebSocket we have to send the heartbeats ourselves. + * + * This also lets us detect zombie connections. + * + * We only ping when the connection has actually gone quiet - any real + * traffic (e.g. an active chatroom) keeps it alive on its own. + * + * This timer lives in the worker because worker timers aren't throttled + * in background tabs the way main-thread timers are. + */ +setInterval(() => { + if (socket?.readyState !== WebSocket.OPEN) return; + if (Date.now() - lastReceiveTime > 3 * KEEPALIVE_INTERVAL) { + socket.close(); // zombie connection + return; + } + if (Date.now() - lastReceiveTime >= KEEPALIVE_RANGE) { + socket.send('|/cmd ping'); + } +}, KEEPALIVE_INTERVAL); + function connectToServer() { if (!serverInfo) return; + if (socket) return; // already connected or connecting const port = serverInfo.protocol === 'https' ? '' : `:${serverInfo.port}`; const url = `${serverInfo.protocol}://${serverInfo.host}${port}${serverInfo.prefix}`; @@ -37,25 +74,40 @@ function connectToServer() { } if (socket) { socket.onopen = () => { + reconnectDelay = 1000; + lastReceiveTime = Date.now(); postMessage({ type: 'connected' }); for (const msg of queue) socket?.send(msg); queue = []; }; socket.onmessage = (e: MessageEvent) => { + lastReceiveTime = Date.now(); + if (e.data.startsWith(PING_RESPONSE)) return; postMessage({ type: 'message', data: e.data }); }; socket.onclose = () => { + socket = null; postMessage({ type: 'disconnected' }); - // scheduleReconnect(); + scheduleReconnect(); }; - socket.onerror = (err: Event) => { - postMessage({ type: 'error', data: (err as any).message || '' }); + socket.onerror = () => { + // if the connection actually died, onclose will fire and handle it socket?.close(); }; return; } return postMessage({ type: 'error' }); } + +function scheduleReconnect() { + if (!shouldReconnect || reconnectTimeout) return; + postMessage({ type: 'retrying', data: Date.now() + reconnectDelay }); + reconnectTimeout = setTimeout(() => { + reconnectTimeout = null; + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_CAP); + if (shouldReconnect) connectToServer(); + }, reconnectDelay); +} diff --git a/play.pokemonshowdown.com/src/client-connection.ts b/play.pokemonshowdown.com/src/client-connection.ts index b9da6d75b..5da07cad7 100644 --- a/play.pokemonshowdown.com/src/client-connection.ts +++ b/play.pokemonshowdown.com/src/client-connection.ts @@ -9,6 +9,8 @@ import { Config, PS } from "./client-main"; declare const SockJS: any; declare const POKEMON_SHOWDOWN_TESTCLIENT_KEY: string | undefined; +const KEEPALIVE_INTERVAL = 25000; +const KEEPALIVE_RANGE = 20000; export class PSConnection { socket: WebSocket | null = null; @@ -16,10 +18,13 @@ export class PSConnection { lastMessageTimeBeforeReconnect = 0; queue: string[] = []; reconnectDelay = 1000; - private reconnectCap = 15000; + private reconnectCap = 60000; private shouldReconnect = true; reconnectTimer: ReturnType | null = null; private worker: Worker | null = null; + lastReceiveTime = Date.now(); + /** the next time we'll attempt a reconnect; 0 means we're not scheduled to retry */ + nextRetryTime = 0; constructor() { const loading = PSStorage.init(); @@ -30,6 +35,26 @@ export class PSConnection { } else { this.initConnection(); } + setInterval(() => this.keepAlive(), KEEPALIVE_INTERVAL); + } + + /** + * Keepalive for direct (non-worker) connections; see the worker for + * the full explanation. Worker connections use the worker's own + * keepalive timer, which has the advantage of not being throttled + * in background tabs. + */ + keepAlive() { + if (this.worker) return; + if (!this.connected) return; + if (Date.now() - this.lastReceiveTime > 3 * KEEPALIVE_INTERVAL) { + // zombie connection; close it so the reconnect logic kicks in + this.socket?.close(); + return; + } + if (Date.now() - this.lastReceiveTime >= KEEPALIVE_RANGE) { + this.send(`|/cmd ping`); + } } initConnection() { @@ -68,13 +93,7 @@ export class PSConnection { const { type, data } = event.data; switch (type) { case 'connected': - console.log('\u2705 (CONNECTED via worker)'); - this.lastMessageTimeBeforeReconnect = parseInt(PS.lastMessageTime) || 0; - this.connected = true; - if (PS.prefs.avatar) worker.postMessage({ type: 'send', data: `/avatar ${PS.prefs.avatar},1` }); - this.queue.forEach(msg => worker.postMessage({ type: 'send', data: msg })); - this.queue = []; - PS.update(); + this.handleConnect(); break; case 'message': PS.receive(data); @@ -82,6 +101,9 @@ export class PSConnection { case 'disconnected': this.handleDisconnect(); break; + case 'retrying': + this.nextRetryTime = data; + break; case 'error': console.warn(`Worker connection error: ${data}`); this.worker = null; @@ -114,26 +136,22 @@ export class PSConnection { const url = `${server.protocol}://${server.host}${port}${server.prefix}`; try { - this.socket = new WebSocket(url.replace('http', 'ws') + '/websocket'); - } catch { this.socket = new SockJS(url, [], { timeout: 5 * 60 * 1000 }); + } catch { + this.socket = new WebSocket(url.replace('http', 'ws') + '/websocket'); } const socket = this.socket!; socket.onopen = () => { - console.log('\u2705 (CONNECTED)'); - this.lastMessageTimeBeforeReconnect = parseInt(PS.lastMessageTime) || 0; - this.connected = true; - this.reconnectDelay = 1000; - if (PS.prefs.avatar) socket.send(`/avatar ${PS.prefs.avatar},1`); - this.queue.forEach(msg => socket.send(msg)); - this.queue = []; - PS.update(); + this.handleConnect(); }; socket.onmessage = (ev: MessageEvent) => { - PS.receive('' + ev.data); + const data = '' + ev.data; + this.lastReceiveTime = Date.now(); + if (data.startsWith('|queryresponse|ping|')) return; + PS.receive(data); }; socket.onclose = () => { @@ -159,6 +177,16 @@ export class PSConnection { } private handleDisconnect() { + this.markDisconnected(); + if (this.worker) { + // worker handles reconnect timer + if (!this.canReconnect()) this.worker.postMessage({ type: 'disconnect' }); + } else { + this.retryConnection(); + } + } + + private markDisconnected() { this.connected = false; PS.isOffline = true; this.socket = null; @@ -166,7 +194,28 @@ export class PSConnection { const room = PS.rooms[roomid]!; if (room.connected === true) room.connected = 'autoreconnect'; } - this.retryConnection(); + PS.update(); + } + + /** + * Happens on connect and reconnect for worker and direct connections + */ + private handleConnect() { + console.log(`\u2705 (CONNECTED${this.worker ? ' via worker' : ''})`); + this.lastMessageTimeBeforeReconnect = parseInt(PS.lastMessageTime) || 0; + this.connected = true; + PS.isOffline = false; + this.reconnectDelay = 1000; + this.nextRetryTime = 0; + this.lastReceiveTime = Date.now(); + + if (PS.prefs.avatar) this.send(`/avatar ${PS.prefs.avatar},1`); + const queue = this.queue; + this.queue = []; + for (const msg of queue) this.send(msg); + + PS.prefs.doAutojoin(); + PS.update(); } @@ -174,6 +223,7 @@ export class PSConnection { if (!this.canReconnect()) return; if (this.reconnectTimer) return; + this.nextRetryTime = Date.now() + this.reconnectDelay; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (!this.connected && this.canReconnect()) { @@ -218,7 +268,6 @@ export class PSConnection { } else { PS.connection.reconnect(); } - PS.prefs.doAutojoin(); } } diff --git a/play.pokemonshowdown.com/src/panel-chat.tsx b/play.pokemonshowdown.com/src/panel-chat.tsx index 3fbdfe2ad..b92d62c69 100644 --- a/play.pokemonshowdown.com/src/panel-chat.tsx +++ b/play.pokemonshowdown.com/src/panel-chat.tsx @@ -8,7 +8,7 @@ import preact from "../js/lib/preact"; import type { PSSubscription } from "./client-core"; import { PS, PSRoom, type RoomOptions, type RoomID, type Team, Config } from "./client-main"; -import { PSView, PSPanelWrapper, PSRoomPanel } from "./panels"; +import { PSView, PSPanelWrapper, PSRoomPanel, ReconnectTimer } from "./panels"; import { TeamForm } from "./panel-mainmenu"; import { BattleLog } from "./battle-log"; import type { Battle } from "./battle"; @@ -208,15 +208,10 @@ export class ChatRoom extends PSRoom { if (!lines[i - 1]) cutOffEnd = i - 1; } } - console.log(`Reconnection log splice: (cutoff: ${cutOffTime})`); - console.log([ - ...lines.slice(0, cutOffStart), - '====================', - ...lines.slice(cutOffStart, cutOffEnd), - '====================', - ...lines.slice(cutOffEnd), - ].join('\n')); lines = lines.slice(cutOffStart, cutOffEnd); + if (lines[0]?.startsWith('|init|')) { + lines[0] = `||Note: Scrollback doesn't go all the way back to when you disconnected.`; + } if (lines.length) { const timestamp = BattleLog.renderTimestamp(cutOffTime, PS.prefs.timestamps?.chatrooms); @@ -1417,7 +1412,7 @@ class ChatPanel extends PSRoomPanel { {} - {PS.connection?.reconnectTimer && (Autoreconnect in {Math.round(PS.connection.reconnectDelay / 1000)}s)} +

} ; } diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx index 3af5b8dc4..e19506b40 100644 --- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx +++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx @@ -9,7 +9,7 @@ import preact from "../js/lib/preact"; import { PSLoginServer } from "./client-connection"; import { PSBackground } from "./client-core"; import { Config, PS, PSRoom, type RoomID, type RoomOptions, type Team } from "./client-main"; -import { PSIcon, PSPanelErrorBoundary, PSPanelWrapper, PSRoomPanel } from "./panels"; +import { PSIcon, PSPanelErrorBoundary, PSPanelWrapper, PSRoomPanel, ReconnectTimer } from "./panels"; import type { BattlesRoom } from "./panel-battle"; import type { ChatRoom } from "./panel-chat"; import type { LadderFormatRoom } from "./panel-ladder"; @@ -676,7 +676,7 @@ class MainMenuPanel extends PSRoomPanel { {} - {PS.connection?.reconnectTimer && (Autoreconnect in {Math.round(PS.connection.reconnectDelay / 1000)}s)} +

} ; } diff --git a/play.pokemonshowdown.com/src/panels.tsx b/play.pokemonshowdown.com/src/panels.tsx index 2bea76a27..b49b0f197 100644 --- a/play.pokemonshowdown.com/src/panels.tsx +++ b/play.pokemonshowdown.com/src/panels.tsx @@ -1075,6 +1075,22 @@ export class PSView extends preact.Component { } } +export class ReconnectTimer extends preact.Component { + timer: ReturnType | null = null; + override componentDidMount() { + this.timer = setInterval(() => this.forceUpdate(), 1000); + } + override componentWillUnmount() { + if (this.timer) clearInterval(this.timer); + } + override render() { + const nextRetryTime = PS.connection?.nextRetryTime; + if (!nextRetryTime) return null; + const secs = Math.ceil((nextRetryTime - Date.now()) / 1000); + return {secs > 0 ? `(Autoreconnect in ${secs}s)` : `(Reconnecting...)`}; + } +} + export function PSIcon( props: { pokemon: string | Pokemon | ServerPokemon | Dex.PokemonSet | null } | { item: string | null } | { type: string, b?: boolean } | { category: string } From d815c89a51afa7d4b2b479ad7085cf62d71c31fb Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Mon, 22 Jun 2026 00:01:51 +0000 Subject: [PATCH 2/6] Preact: Support custom rules in challenge box --- play.pokemonshowdown.com/src/panel-chat.tsx | 3 +- .../src/panel-mainmenu.tsx | 142 ++++++++++++++---- play.pokemonshowdown.com/style/client2.css | 16 +- 3 files changed, 125 insertions(+), 36 deletions(-) diff --git a/play.pokemonshowdown.com/src/panel-chat.tsx b/play.pokemonshowdown.com/src/panel-chat.tsx index b92d62c69..c1f2d8c01 100644 --- a/play.pokemonshowdown.com/src/panel-chat.tsx +++ b/play.pokemonshowdown.com/src/panel-chat.tsx @@ -1420,10 +1420,11 @@ class ChatPanel extends PSRoomPanel { override render() { const room = this.props.room; const tinyLayout = room.width < 450; + const challengeOpen = room.challengeMenuOpen || room.challenging || room.challenged; return {this.renderControls()} diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx index e19506b40..410cbeb0d 100644 --- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx +++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx @@ -817,6 +817,7 @@ export class FormatDropdown extends preact.Component<{ render() { this.format = this.props.format || this.format || this.props.defaultFormat || ''; let [formatName, customRules] = this.format.split('@@@'); + customRules = customRules?.replace(/,/g, ', '); if (window.BattleLog) formatName = BattleLog.formatName(formatName); if (this.props.format && !this.props.onChange) { // There's intentionally no `disabled` prop. If this is out of sync @@ -895,10 +896,59 @@ export class TeamForm extends preact.Component<{ format = ''; teraPreview = false; bestOf = false; + bestOfValue = '3'; + customRules = false; + customRuleText = ''; itemClause = false; changeFormat = (ev: Event) => { - this.format = (ev.target as HTMLButtonElement).value; + this.setFormat((ev.target as HTMLButtonElement).value); }; + setFormat(format: string) { + const [baseFormat, customRules] = format.split('@@@'); + this.format = baseFormat; + this.loadCustomRules(customRules); + }; + loadCustomRules(customRules: string) { + this.bestOf = false; + this.bestOfValue = '3'; + this.teraPreview = false; + this.itemClause = false; + if (!customRules) { + this.customRules = false; + this.customRuleText = ''; + return; + } + + this.customRules = true; + const unknownRules: string[] = []; + for (const rule of customRules.split(',')) { + const trimmedRule = rule.trim(); + if (!trimmedRule) continue; + const bestOfMatch = /^best[-\s]*of\s*=\s*(\d+)$/i.exec(trimmedRule); + if (bestOfMatch) { + this.bestOf = true; + this.bestOfValue = bestOfMatch[1]; + } else if (/^tera\s+type\s+preview$/i.test(trimmedRule)) { + this.teraPreview = true; + } else if (/^item\s+clause\s*=\s*1$/i.test(trimmedRule)) { + this.itemClause = true; + } else { + unknownRules.push(trimmedRule); + } + } + this.customRuleText = unknownRules.join('\n'); + }; + changeBestOfValue = (ev: Event) => { + this.bestOfValue = (ev.target as HTMLInputElement).value; + }; + changeCustomRules = (ev: Event) => { + this.customRuleText = (ev.target as HTMLTextAreaElement).value; + }; + addCustomRules(format: string, rules: string[]) { + if (!rules.length) return format; + const hasCustomRules = format.includes('@@@'); + return `${format}${hasCustomRules ? ', ' : '@@@ '}${rules.join(', ')}`; + } submit = (ev: Event, validate?: 'validate') => { ev.preventDefault(); let format = this.format; @@ -913,19 +963,18 @@ export class TeamForm extends preact.Component<{ }); return; } - if (this.teraPreview) { - const hasCustomRules = format.includes('@@@'); - format = `${format}${hasCustomRules ? ', Tera Type Preview' : '@@@ Tera Type Preview'}`; - } - if (this.bestOf) { - const hasCustomRules = format.includes('@@@'); - const value = this.base?.querySelector('input[name=bestofvalue]')?.value; - format = `${format}${hasCustomRules ? `, Best of = ${value!}` : `@@@ Best of = ${value!}`}`; + const customRules: string[] = []; + if (this.customRules) { + if (this.bestOf) { + customRules.push(`Best of = ${this.bestOfValue || '3'}`); + } + if (this.teraPreview) customRules.push('Tera Type Preview'); + customRules.push(...this.customRuleText.split('\n').map(rule => rule.trim()).filter(Boolean)); } if (this.itemClause) { - const hasCustomRules = format.includes('@@@'); - format = `${format}${hasCustomRules ? ', Item Clause = 1' : '@@@ Item Clause = 1'}`; + customRules.push('Item Clause = 1'); } + format = this.addCustomRules(format, customRules); PS.teams.loadTeam(team).then(() => { (validate === 'validate' ? this.props.onValidate : this.props.onSubmit)?.(ev, format, team); }); @@ -935,7 +984,15 @@ export class TeamForm extends preact.Component<{ const rule = (ev.target as HTMLInputElement)?.name; if (rule === 'terapreview') this.teraPreview = checked; if (rule === 'bestof') this.bestOf = checked; - if (rule === 'itemclause=1') this.itemClause = checked; + if (rule === 'customrules') { + this.customRules = checked; + if (!checked) { + this.bestOf = false; + this.teraPreview = false; + } + this.forceUpdate(); + } + if (rule === 'itemclause') this.itemClause = checked; }; handleClick = (ev: Event) => { let target = ev.target as HTMLButtonElement | null; @@ -948,7 +1005,6 @@ export class TeamForm extends preact.Component<{ } }; render() { - const formatId = toID(this.format.split('@@@')[0]); if (window.BattleFormats) { this.format ||= this.props.defaultFormat || ''; if (!this.format) { @@ -971,9 +1027,13 @@ export class TeamForm extends preact.Component<{ if (this.props.defaultFormat?.startsWith('!!')) { // The !! means that it overrides any current format, and will only be // sent as a prop once - this.format = this.props.defaultFormat.slice(2); + this.setFormat(this.props.defaultFormat.slice(2)); } if (this.props.format) this.format = this.props.format; + if (!this.props.format && this.format.includes('@@@')) this.setFormat(this.format); + const formatId = toID(this.format.split('@@@')[0]); + const format = window.BattleFormats[formatId]; + const showCustomRules = this.props.selectType === 'challenge' && !this.props.format; return
{!this.props.hideFormat &&

- {this.props.selectType === 'challenge' && - window.BattleFormats[formatId]?.teraPreviewDefault &&

-

} - {this.props.selectType === 'challenge' && - window.BattleFormats[formatId]?.bestOfDefault &&

-

:
+ + {(format?.bestOfDefault || this.bestOf) &&

+

} - {this.props.selectType === 'challenge' && - window.BattleFormats[formatId]?.itemClauseDefault &&

-

} + Best-of- +

} + {(format?.teraPreviewDefault || this.teraPreview) &&

+ +

} + {(format?.itemClauseDefault || this.itemClause) &&

+ +

} +