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 ]+?src|]+?href|]+?src)="\/(.*?)(\?[a-z0-9]*?)?"/g, runReplace + /(]+?src|]+?href|]+?src)="\/(.*?)(\?[a-z0-9]*?)?"/g, + (_, tagStart, url) => { + let hash = Math.random(); // just in case creating the hash fails + const routes = JSON.parse(fs.readFileSync('config/routes.json')); + try { + let filepath = 'replay.pokemonshowdown.com/' + url; + if (url.includes('/' + routes.client + '/')) { + const filename = url.replace('/' + routes.client + '/', ''); + filepath = filename; + } + const fstr = fs.readFileSync(filepath, { encoding: 'utf8' }); + hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8); + } catch {} + url = url.replace('/replay.pokemonshowdown.com/', '/' + routes.replays + '/'); + url = url.replace('/dex.pokemonshowdown.com/', '/' + routes.dex + '/'); + url = url.replace('/play.pokemonshowdown.com/', '/' + routes.client + '/'); + url = url.replace('/pokemonshowdown.com/users/', '/' + routes.users + '/'); + url = url.replace('/pokemonshowdown.com/', '/' + routes.root + '/'); + + return tagStart + '="/' + url + '?' + hash + '"'; + } ); console.log("DONE"); process.stdout.write("Writing new `wrapper.inc.php` file... "); - fs.writeFileSync('theme/wrapper.inc.php', indexContents); + fs.writeFileSync('replay.pokemonshowdown.com/theme/wrapper.inc.php', indexContents); console.log("DONE"); } -function runReplace(a, b, c) { - let hash = Math.random(); // just in case creating the hash fails - const routes = JSON.parse(fs.readFileSync('../config/routes.json')); - try { - let filepath = c; - if (c.includes('/' + routes.client + '/')) { - const filename = c.replace('/' + routes.client + '/', ''); - filepath = '../' + filename; - } - const fstr = fs.readFileSync(filepath, { encoding: 'utf8' }); - hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8); - } catch {} - c = c.replace('/replay.pokemonshowdown.com/', '/' + routes.replays + '/'); - c = c.replace('/dex.pokemonshowdown.com/', '/' + routes.dex + '/'); - c = c.replace('/play.pokemonshowdown.com/', '/' + routes.client + '/'); - c = c.replace('/pokemonshowdown.com/users/', '/' + routes.users + '/'); - c = c.replace('/pokemonshowdown.com/', '/' + routes.root + '/'); - - return b + '="/' + c + '?' + hash + '"'; -} - updateIndex(); diff --git a/build-tools/compiler.js b/build-tools/compiler.mjs similarity index 95% rename from build-tools/compiler.js rename to build-tools/compiler.mjs index 57042abc7..42a51b6bf 100644 --- a/build-tools/compiler.js +++ b/build-tools/compiler.mjs @@ -10,10 +10,10 @@ * @license MIT */ -const babel = require('@babel/core'); -const fs = require('fs'); -const path = require('path'); -const sourceMap = require('source-map'); +import * as babel from '@babel/core'; +import fs from 'node:fs'; +import path from 'node:path'; +import sourceMap from 'source-map'; const VERBOSE = false; @@ -37,7 +37,6 @@ function outputFileSync(filePath, res, opts) { function slash(filePath) { const isExtendedLengthPath = /^\\\\\?\\/.test(filePath); - // eslint-disable-next-line no-control-regex const hasNonAscii = /[^\u0000-\u0080]+/.test(filePath); if (isExtendedLengthPath || hasNonAscii) { @@ -218,6 +217,4 @@ function compileToFile(srcFile, destFile, opts) { return results.length; } -exports.compileToDir = compileToDir; - -exports.compileToFile = compileToFile; +export { compileToDir, compileToFile }; diff --git a/build-tools/sets b/build-tools/sets new file mode 100755 index 000000000..1be9eb50c --- /dev/null +++ b/build-tools/sets @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import * as child_process from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +process.chdir(import.meta.dirname); + +const shell = cmd => child_process.execSync(cmd, { + cwd: path.resolve('../caches/pokemon-showdown/'), +}); + +shell("node build"); + +console.log('Creating set files...'); + +shell("node tools/set-import/index.js 1.0.0"); + +console.log('Set files created. Moving...'); + +const targetDir = path.resolve('../play.pokemonshowdown.com/data/sets/'); +fs.mkdirSync(targetDir, { recursive: true }); + +const dir = path.resolve("../caches/pokemon-showdown/tools/set-import/sets"); +const files = fs.readdirSync(dir); +for (const file of files) { + if (file.endsWith('.json')) { + fs.renameSync(path.join(dir, file), path.join(targetDir, file)); + } +} +console.log("DONE. " + files.length + " sets files moved."); diff --git a/build-tools/update b/build-tools/update index b503fc635..88d253a9b 100755 --- a/build-tools/update +++ b/build-tools/update @@ -8,15 +8,12 @@ "use strict"; -const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); -const child_process = require('child_process'); -const compiler = require('./compiler'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const child_process = require('node:child_process'); +const compiler = require('./compiler.mjs'); -const thisDir = __dirname; -const rootDir = path.resolve(thisDir, '..'); -process.chdir(rootDir); +process.chdir(__dirname + '/..'); const AUTOCONFIG_START = '/*** Begin automatically generated configuration ***/'; const AUTOCONFIG_END = '/*** End automatically generated configuration ***/'; @@ -210,7 +207,7 @@ let stdout = ''; let newsid = 0; let news = '[failed to retrieve news]'; try { - stdout = child_process.execSync('php ' + path.resolve(thisDir, 'news-embed.php'), { + stdout = child_process.execSync('php build-tools/news-embed.php', { stdio: 'pipe', }); } catch (e) { 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`); diff --git a/play.pokemonshowdown.com/index-new.html b/play.pokemonshowdown.com/index-new.html index 0edf86076..4dc1c789a 100644 --- a/play.pokemonshowdown.com/index-new.html +++ b/play.pokemonshowdown.com/index-new.html @@ -76,7 +76,7 @@ https://psim.us/dev linkEl.href = url; document.head.appendChild(linkEl); } - linkStyle("/style/sim-types.css"); + linkStyle("/style/sim-types.css?"); linkStyle("/style/utilichart.css?"); linkStyle("/style/battle-search.css?"); linkStyle("/style/font-awesome.css"); diff --git a/play.pokemonshowdown.com/index.html b/play.pokemonshowdown.com/index.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/play.pokemonshowdown.com/sprites/afd/index.php b/play.pokemonshowdown.com/sprites/afd/index.php index 1b26d50fb..a72b1c25c 100644 --- a/play.pokemonshowdown.com/sprites/afd/index.php +++ b/play.pokemonshowdown.com/sprites/afd/index.php @@ -8,7 +8,7 @@ function dirindex_intro() { ?>

April Fool's front sprites

These are the front sprites. You can also view the back sprites.

-

» pokemon-showdown-afd-2020.zip

+

» pokemon-showdown-afd-2026.zip

+

Sprites can be downloaded prepackaged:

+

» sprites.zip

+

You might also be interested in the PokeAPI sprites GitHub repository. +

- Did you want to see a list of all custom avatars? Sorry, that's private. + Did you want to see a list of all custom avatars? Out of respect for users who want to be private, that's not available here. Sorry! Most users do like theirs to be public, though, so you can find details in.

+

- Your avatar can be changed using the Options menu (it looks like ) in the upper right of Pokemon Showdown. + Your own avatar can be changed using the Options menu (it looks like ) in the upper right of Pokemon Showdown.

| 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/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index 43affa1ea..cc5fb5340 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -2503,7 +2503,7 @@ export const PS = new class extends PSModel { if (index === -1) return; const unreadRooms = rooms.filter((room, i) => - PS.rooms[room]?.isSubtleNotifying && + (PS.rooms[room]?.isSubtleNotifying || PS.rooms[room]?.notifications.length) && (direction === 'left' ? i < index : i > index) ); diff --git a/play.pokemonshowdown.com/src/miniedit.ts b/play.pokemonshowdown.com/src/miniedit.ts index eeac4b7a3..1515fc135 100644 --- a/play.pokemonshowdown.com/src/miniedit.ts +++ b/play.pokemonshowdown.com/src/miniedit.ts @@ -158,6 +158,8 @@ const HTML_BLOCK_TAGS = [ 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL', ]; +// Pasting can disrupt newlines, so they get manually processed here. +// Unfortunately, this is massively complicated by an Android Chrome bug. export class MiniEditPastePlugin { constructor(editor: MiniEdit) { editor.element.addEventListener('paste', e => { @@ -176,7 +178,7 @@ export class MiniEditPastePlugin { if (!html || text.includes('\n')) return text; const htmlText = this.htmlToPlainText(html); - return htmlText.includes('\n') ? htmlText : text; + return htmlText.trim().includes('\n') ? htmlText : text; } htmlToPlainText(html: string): string { @@ -188,7 +190,7 @@ export class MiniEditPastePlugin { .replace(//g, '') .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '') // handle newlines - .replace(/\n/g, '
') // in case they're in
?
+			// .replace(/\n/g, '
') // in case they're in
?
 			.replace(new RegExp(`]*>`, 'gi'), '\n')
 			.replace(/\n{2,}/g, '\n')
 			.replace(/]*>\n?/gi, '\n')
diff --git a/play.pokemonshowdown.com/src/panel-chat.tsx b/play.pokemonshowdown.com/src/panel-chat.tsx
index 3fbdfe2ad..c1f2d8c01 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)}
+				
 			

} ; } @@ -1425,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 3af5b8dc4..410cbeb0d 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)} +

} ; } @@ -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) &&

+ +

} +