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 ;
}
diff --git a/play.pokemonshowdown.com/style/client2.css b/play.pokemonshowdown.com/style/client2.css
index ad5c27ec1..e3f9c8905 100644
--- a/play.pokemonshowdown.com/style/client2.css
+++ b/play.pokemonshowdown.com/style/client2.css
@@ -923,6 +923,10 @@ p.or:after {
background: #052f68;
color: #b3d2fc;
}
+.challenge.outgoing fieldset {
+ border: 1px solid #217bf5;
+ border-radius: 5px;
+}
.challenge p {
margin: 4px 0;
}
@@ -1175,6 +1179,9 @@ form.menugroup {
min-height: 80px;
max-height: 300px;
}
+.mini-window-flex .chat-log.challenge-open {
+ max-height: 520px;
+}
.debug {
display: none;
}
@@ -2058,7 +2065,6 @@ pre.textbox.textbox-empty[placeholder]:before {
text-align: left;
font-family: Verdana, Helvetica, Arial, sans-serif;
text-decoration: none;
- white-space: nowrap;
cursor: pointer;
border-radius: 4px;
@@ -2074,6 +2080,11 @@ pre.textbox.textbox-empty[placeholder]:before {
.team {
height: 49px;
overflow: hidden;
+ white-space: nowrap;
+}
+.select {
+ min-height: 32px;
+ height: auto;
}
.team.pc-box {
min-height: 74px;
@@ -2167,9 +2178,6 @@ pre.textbox.textbox-empty[placeholder]:before {
.select:disabled:before {
color: #AAAAAA;
}
-.select:disabled small {
- opacity: 0.7;
-}
.select.preselected,
.select:disabled.preselected {
color: black;
From e3b2954f43b6b2270099836c50d9283ad920d62b Mon Sep 17 00:00:00 2001
From: Guangcong Luo
Date: Mon, 22 Jun 2026 11:16:48 +0000
Subject: [PATCH 3/6] Add zip-sprites script
This will make it easier to package our not-in-repo directories for
redistribution. And, uh, backups.
---
build-tools/zip-sprites | 56 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100755 build-tools/zip-sprites
diff --git a/build-tools/zip-sprites b/build-tools/zip-sprites
new file mode 100755
index 000000000..e0d0ab9a4
--- /dev/null
+++ b/build-tools/zip-sprites
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+'use strict';
+
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+
+process.chdir(import.meta.dirname + '/..');
+
+const CONFIGS = {
+ sprites: {
+ inputDir: 'play.pokemonshowdown.com',
+ outputFile: 'pokemonshowdown.com/files/resources/sprites.zip',
+ entries: ['sprites'],
+ excludes: ['sprites/trainers-custom/*'],
+ },
+ audio: {
+ inputDir: 'play.pokemonshowdown.com',
+ outputFile: 'pokemonshowdown.com/files/resources/audio.zip',
+ entries: ['audio'],
+ },
+ afd: {
+ inputDir: 'play.pokemonshowdown.com',
+ outputFile: 'pokemonshowdown.com/files/pokemon-showdown-afd.zip',
+ entries: ['sprites/afd', 'sprites/afd-back', 'sprites/afd-shiny', 'sprites/afd-back-shiny'],
+ },
+ backup: {
+ inputDir: '.',
+ outputFile: '../backup.zip',
+ entries: ['config', 'pokemonshowdown.com/images', 'pokemonshowdown.com/files/showdown-coppa-form.pdf', 'play.pokemonshowdown.com/audio', 'play.pokemonshowdown.com/sprites'],
+ },
+};
+
+const config = CONFIGS[process.argv[2] || '!ERROR!'];
+if (!config) {
+ console.error(`Usage: build-tools/zip-sprites [${Object.keys(CONFIGS).join('|')}]`);
+ process.exit(1);
+}
+
+process.stdout.write(`Zipping ${config.outputFile}... `);
+
+fs.rmSync(config.outputFile, { force: true });
+
+const args = ['-qr', path.resolve(config.outputFile), ...config.entries, ...(
+ config.excludes?.length ? ['-x', ...config.excludes] : []
+)];
+
+const result = spawnSync('zip', args, {
+ cwd: config.inputDir,
+ stdio: 'inherit',
+});
+
+if (result.error) console.error(result.error.message);
+if (result.error || result.status) process.exit(result.status || 1);
+
+console.log(`DONE`);
From 57e9c46f9da8353832edcb39d8f9808bccfd5704 Mon Sep 17 00:00:00 2001
From: Guangcong Luo
Date: Wed, 17 Jun 2026 00:53:22 +0000
Subject: [PATCH 4/6] Preact minor updates batch 34
- Fix teams count in teambuilder
- Fix news publishing
- Modernize build-tools
- Not all of them could be converted to ESM but I did what I could
- In vertical tabs, show notification on tabbar when receiving DMs
- This is closer to Discord's behavior
- Add more info to the sprites pages
- Support custom avatars on unregistered servers
- Support reaching DMs with Alt+Shift+Arrows
- Improve pasting
- Still quite buggy on Android, but should be much less so on desktop
---
build-tools/build-commands | 2 +-
build-tools/build-indexes | 8 ++-
build-tools/build-learnsets | 20 +++----
build-tools/build-minidex | 38 ++++++-------
build-tools/build-replays | 53 +++++++++----------
build-tools/{compiler.js => compiler.mjs} | 13 ++---
build-tools/update | 15 +++---
play.pokemonshowdown.com/index.html | 0
.../sprites/afd/index.php | 2 +-
play.pokemonshowdown.com/sprites/index.php | 11 ++++
.../sprites/trainers-custom/index.php | 9 +++-
play.pokemonshowdown.com/src/battle-dex.ts | 4 +-
play.pokemonshowdown.com/src/client-main.ts | 2 +-
play.pokemonshowdown.com/src/miniedit.ts | 6 ++-
.../src/panel-teambuilder.tsx | 7 ++-
play.pokemonshowdown.com/src/panel-topbar.tsx | 11 ++--
pokemonshowdown.com/news/manage.php | 15 +++++-
17 files changed, 124 insertions(+), 92 deletions(-)
rename build-tools/{compiler.js => compiler.mjs} (95%)
delete mode 100644 play.pokemonshowdown.com/index.html
create mode 100644 play.pokemonshowdown.com/sprites/index.php
diff --git a/build-tools/build-commands b/build-tools/build-commands
index 787f2f254..7fa38592c 100755
--- a/build-tools/build-commands
+++ b/build-tools/build-commands
@@ -3,7 +3,7 @@
* Builds out a cache of commands from the server & their help commands
*/
-const fs = require('fs');
+const fs = require('node:fs');
console.log('Building `data/commands.json` index...');
diff --git a/build-tools/build-indexes b/build-tools/build-indexes
index 87fd94734..6285e88b8 100755
--- a/build-tools/build-indexes
+++ b/build-tools/build-indexes
@@ -1,12 +1,10 @@
#!/usr/bin/env node
'use strict';
-const fs = require("fs");
-const path = require('path');
-const child_process = require("child_process");
+const fs = require('node:fs');
+const child_process = require('node:child_process');
-const rootDir = path.resolve(__dirname, '..');
-process.chdir(rootDir);
+process.chdir(__dirname + '/..');
if (!fs.existsSync('caches/pokemon-showdown')) {
child_process.execSync('git clone https://github.com/smogon/pokemon-showdown.git', {
diff --git a/build-tools/build-learnsets b/build-tools/build-learnsets
index c0e9aeddb..8791bd010 100755
--- a/build-tools/build-learnsets
+++ b/build-tools/build-learnsets
@@ -11,14 +11,14 @@
"use strict";
-const path = require('path');
-const fs = require('fs');
+import fs from 'node:fs';
-const thisFile = __filename;
-const thisDir = __dirname;
-const rootDir = path.resolve(thisDir, '../play.pokemonshowdown.com');
+import dexModule from '../caches/pokemon-showdown/dist/sim/dex.js';
-const Dex = require('../caches/pokemon-showdown/dist/sim/dex').Dex;
+process.chdir(import.meta.dirname + '/..');
+
+const thisFile = 'build-tools/build-learnsets';
+const { Dex } = dexModule;
const toID = Dex.toID;
function updateLearnsets(callback) {
@@ -75,14 +75,14 @@ function updateLearnsets(callback) {
buf.push(speciesid + ':{learnset:' + lsetSerialized + '}');
}
- const writeStream = fs.createWriteStream(path.join(rootDir, 'data', 'learnsets-g6.js')).on('error', callback);
+ const writeStream = fs.createWriteStream('play.pokemonshowdown.com/data/learnsets-g6.js').on('error', callback);
writeStream.write('exports.BattleLearnsets = {\n\t' + buf.join(',\n\t') + '\n};\n');
writeStream.end(callback);
}
let indexStats, updateStats, indexMTime, updateMTime;
try {
- indexStats = fs.statSync(path.join(rootDir, 'index.html'));
+ indexStats = fs.statSync('play.pokemonshowdown.com/index.html');
indexMTime = indexStats.mtime.getTime();
} catch (err) {
if (err.code !== 'ENOENT') throw err;
@@ -102,7 +102,7 @@ let learnsetsG6Stats;
let learnsetsG6ToUpdate = true;
try {
- learnsetsStats = fs.statSync(path.join(rootDir, 'data', 'learnsets.js'));
+ learnsetsStats = fs.statSync('play.pokemonshowdown.com/data/learnsets.js');
} catch {
// Couldn't find learnsets.js, but that's not the end of the world: skip to next task.
console.error("Couldn't find `data/learnsets.js`. Task aborted.");
@@ -110,7 +110,7 @@ try {
}
if (learnsetsG6ToUpdate) {
try {
- learnsetsG6Stats = fs.statSync(path.join(rootDir, 'data', 'learnsets-g6.js'));
+ learnsetsG6Stats = fs.statSync('play.pokemonshowdown.com/data/learnsets-g6.js');
} catch (err) {
if (err.code === 'ENOENT') {
// It doesn't exist currently, but it will by the end of the script execution.
diff --git a/build-tools/build-minidex b/build-tools/build-minidex
index e42ece641..e7a5b22b8 100755
--- a/build-tools/build-minidex
+++ b/build-tools/build-minidex
@@ -1,12 +1,14 @@
#!/usr/bin/env node
'use strict';
-const fs = require("fs");
-const path = require("path");
-process.chdir(path.resolve(__dirname, '../play.pokemonshowdown.com'));
-const imageSize = require('image-size');
+import fs from 'node:fs';
+import imageSize from 'image-size';
-const Dex = require('./../caches/pokemon-showdown/dist/sim/dex').Dex;
+import dexModule from '../caches/pokemon-showdown/dist/sim/dex.js';
+
+process.chdir(import.meta.dirname + '/..');
+
+const { Dex } = dexModule;
const toID = Dex.toID;
process.stdout.write("Updating animated sprite dimensions... ");
@@ -50,13 +52,13 @@ function updateSizes() {
{
const row = { num: species.num };
- const frontSize = sizeObj('sprites/ani/' + spriteid + '.gif');
+ const frontSize = sizeObj('play.pokemonshowdown.com/sprites/ani/' + spriteid + '.gif');
if (frontSize) row.front = frontSize;
- const frontSizeF = sizeObj('sprites/ani/' + spriteid + '-f.gif');
+ const frontSizeF = sizeObj('play.pokemonshowdown.com/sprites/ani/' + spriteid + '-f.gif');
if (frontSizeF) row.frontf = frontSizeF;
- const backSize = sizeObj('sprites/ani-back/' + spriteid + '.gif');
+ const backSize = sizeObj('play.pokemonshowdown.com/sprites/ani-back/' + spriteid + '.gif');
if (backSize) row.back = backSize;
- const backSizeF = sizeObj('sprites/ani-back/' + spriteid + '-f.gif');
+ const backSizeF = sizeObj('play.pokemonshowdown.com/sprites/ani-back/' + spriteid + '-f.gif');
if (backSizeF) row.backf = backSizeF;
if (row.front || row.back || !row.forme) {
buf += `\t${id}:` + JSON.stringify(row).replace(/"/g, '') + `,\n`;
@@ -65,13 +67,13 @@ function updateSizes() {
{
const g5row = { num: species.num };
- const frontSize = sizeObj('sprites/gen5ani/' + spriteid + '.gif');
+ const frontSize = sizeObj('play.pokemonshowdown.com/sprites/gen5ani/' + spriteid + '.gif');
if (frontSize) g5row.front = frontSize;
- const frontSizeF = sizeObj('sprites/gen5ani/' + spriteid + '-f.gif');
+ const frontSizeF = sizeObj('play.pokemonshowdown.com/sprites/gen5ani/' + spriteid + '-f.gif');
if (frontSizeF) g5row.frontf = frontSizeF;
- const backSize = sizeObj('sprites/gen5ani-back/' + spriteid + '.gif');
+ const backSize = sizeObj('play.pokemonshowdown.com/sprites/gen5ani-back/' + spriteid + '.gif');
if (backSize) g5row.back = backSize;
- const backSizeF = sizeObj('sprites/gen5ani-back/' + spriteid + '-f.gif');
+ const backSizeF = sizeObj('play.pokemonshowdown.com/sprites/gen5ani-back/' + spriteid + '-f.gif');
if (backSizeF) g5row.backf = backSizeF;
if (g5row.front || g5row.back || !g5row.forme) {
g5buf += `\t${id}:` + JSON.stringify(g5row).replace(/"/g, '') + `,\n`;
@@ -87,17 +89,17 @@ function updateSizes() {
};
`;
- fs.writeFileSync('data/pokedex-mini.js', buf);
- fs.writeFileSync('data/pokedex-mini-bw.js', g5buf);
+ fs.writeFileSync('play.pokemonshowdown.com/data/pokedex-mini.js', buf);
+ fs.writeFileSync('play.pokemonshowdown.com/data/pokedex-mini-bw.js', g5buf);
}
-if (fs.existsSync('sprites/ani/')) {
+if (fs.existsSync('play.pokemonshowdown.com/sprites/ani/')) {
updateSizes();
console.log('DONE');
} else {
try {
- fs.unlinkSync('data/pokedex-mini.js');
- fs.unlinkSync('data/pokedex-mini-bw.js');
+ fs.unlinkSync('play.pokemonshowdown.com/data/pokedex-mini.js');
+ fs.unlinkSync('play.pokemonshowdown.com/data/pokedex-mini-bw.js');
} catch {}
console.log('SKIPPED');
}
diff --git a/build-tools/build-replays b/build-tools/build-replays
index c7406750c..664ada06f 100755
--- a/build-tools/build-replays
+++ b/build-tools/build-replays
@@ -5,46 +5,45 @@
* It can be removed once replays/manage is ported to New Replays.
*/
-const fs = require('fs');
-const crypto = require('crypto');
+import crypto from 'node:crypto';
+import fs from 'node:fs';
-process.chdir(__dirname + '/../replay.pokemonshowdown.com');
+process.chdir(import.meta.dirname + '/..');
function updateIndex() {
- let indexContents = fs.readFileSync('theme/wrapper.inc.template.php', { encoding: 'utf8' });
+ let indexContents = fs.readFileSync('replay.pokemonshowdown.com/theme/wrapper.inc.template.php', { encoding: 'utf8' });
// add hashes to js and css files
process.stdout.write("Updating hashes... ");
// Check for