Merge branch 'master' of https://github.com/smogon/pokemon-showdown-client into update-teambuilder-big-buttons

This commit is contained in:
ry
2026-06-26 01:05:54 -05:00
27 changed files with 621 additions and 166 deletions

View File

@@ -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...');

View File

@@ -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', {

View File

@@ -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.

View File

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

View File

@@ -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 <script, <link or <img so we don't add useless hashes to <a
indexContents = indexContents.replace(
/(<script[^>]+?src|<link[^>]+?href|<img[^>]+?src)="\/(.*?)(\?[a-z0-9]*?)?"/g, runReplace
/(<script[^>]+?src|<link[^>]+?href|<img[^>]+?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();

View File

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

30
build-tools/sets Executable file
View File

@@ -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.");

View File

@@ -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) {

56
build-tools/zip-sprites Executable file
View File

@@ -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`);

View File

@@ -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");

View File

@@ -8,7 +8,7 @@ function dirindex_intro() {
?>
<h1 style="font-size: 12pt;">April Fool's front sprites</h1>
<p>These are the front sprites. You can also <a href="../afd-back/">view the back sprites</a>.</p>
<p>&raquo; <a href="//www.pokemonshowdown.com/files/pokemon-showdown-afd-2020.zip"><strong><i class="fa fa-file-archive-o"></i> pokemon-showdown-afd-2020.zip</strong></a></p>
<p>&raquo; <a href="//www.pokemonshowdown.com/files/pokemon-showdown-afd-2026.zip"><strong><i class="fa fa-file-archive-o"></i> pokemon-showdown-afd-2026.zip</strong></a></p>
<?php
}

View File

@@ -0,0 +1,11 @@
<?php
function dirindex_intro() {
?>
<p>Sprites can be downloaded prepackaged:</p>
<p>&raquo; <a href="//www.pokemonshowdown.com/files/resources/sprites.zip"><strong><i class="fa fa-file-archive-o"></i> sprites.zip</strong></a></p>
<p>You might also be interested in <a href="https://github.com/PokeAPI/sprites">the PokeAPI sprites GitHub repository</a>.
<?php
}
require_once '../dirindex/dirindex.php';

View File

@@ -7,10 +7,15 @@ function dirindex_title() {
function dirindex_intro() {
?>
<p>
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.
</p>
<ul>
<li><a href="https://www.smogon.com/smeargle/customs/">The custom avatar index</a></li>
<li><a href="https://www.smogon.com/forums/threads/ps-custom-avatars.3725920/">The custom avatars announcement thread</a></li>
<li><a href="https://www.smogon.com/forums/threads/all-avatars-on-pok%C3%A9mon-showdown.3535547/page-21">The custom avatars discussion thread</a></li>
</ul>
<p>
Your avatar can be changed using the Options menu (it looks like <i class="fa fa-cog"></i>) in the upper right of Pokemon Showdown.
Your own avatar can be changed using the Options menu (it looks like <i class="fa fa-cog"></i>) in the upper right of Pokemon Showdown.
</p>
</main>
<?php

View File

@@ -295,7 +295,9 @@ export const Dex = new class implements ModdedDex {
if (avatar.startsWith('#')) {
return Dex.resourcePrefix + 'sprites/trainers-custom/' + toID(avatar.substr(1)) + '.png';
}
if (avatar.includes('.') && window.Config?.server?.registered) {
if (avatar.includes('.')) {
// previously checked `&& window.Config?.server?.registered`
// currently doesn't, bc server registration isn't a thing anymore
// custom avatar served by the server
const protocol = (Config.server.port === 443) ? 'https' : 'http';
const server = `${protocol}://${Config.server.host}:${Config.server.port}`;

View File

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

View File

@@ -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<typeof setTimeout> | 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();
}
}

View File

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

View File

@@ -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(/<!--[\s\S]*?-->/g, '')
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '')
// handle newlines
.replace(/\n/g, '<br>') // in case they're in <pre>?
// .replace(/\n/g, '<br>') // in case they're in <pre>?
.replace(new RegExp(`</?(?:${HTML_BLOCK_TAGS.join('|')})\\b[^>]*>`, 'gi'), '\n')
.replace(/\n{2,}/g, '\n')
.replace(/<br\b[^>]*>\n?/gi, '\n')

View File

@@ -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<ChatRoom> {
<button class="button" data-cmd="/reconnect">
<i class="fa fa-plug" aria-hidden></i> <strong>Reconnect</strong>
</button> {}
{PS.connection?.reconnectTimer && <small>(Autoreconnect in {Math.round(PS.connection.reconnectDelay / 1000)}s)</small>}
<ReconnectTimer />
</p>}
</>;
}
@@ -1425,10 +1420,11 @@ class ChatPanel extends PSRoomPanel<ChatRoom> {
override render() {
const room = this.props.room;
const tinyLayout = room.width < 450;
const challengeOpen = room.challengeMenuOpen || room.challenging || room.challenged;
return <PSPanelWrapper room={room} focusClick noScroll fullSize>
<ChatLog
class={`chat-log${tinyLayout ? '' : ' hasuserlist'}`} room={this.props.room}
class={`chat-log${tinyLayout ? '' : ' hasuserlist'}${challengeOpen ? ' challenge-open' : ''}`} room={this.props.room}
left={tinyLayout ? 0 : 146} top={room.tour?.info.isActive ? 30 : 0}
>
{this.renderControls()}

View File

@@ -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<MainMenuRoom> {
<button class="button" data-cmd="/reconnect">
<i class="fa fa-plug" aria-hidden></i> <strong>Reconnect</strong>
</button> {}
{PS.connection?.reconnectTimer && <small>(Autoreconnect in {Math.round(PS.connection.reconnectDelay / 1000)}s)</small>}
<ReconnectTimer />
</p>}
</TeamForm>;
}
@@ -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<HTMLInputElement>('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 <form class={this.props.class} onSubmit={this.submit} onClick={this.handleClick}>
{!this.props.hideFormat && <p>
<label class="label">
@@ -990,24 +1050,44 @@ export class TeamForm extends preact.Component<{
<TeamDropdown format={this.props.teamFormat || this.format} />
</label>
</p>
{this.props.selectType === 'challenge' &&
window.BattleFormats[formatId]?.teraPreviewDefault && <p>
<label class="checkbox">
<input type="checkbox" name="terapreview" onChange={this.toggleCustomRule} />
<abbr title="Start a battle with Tera Type Preview">Tera Type Preview</abbr></label></p>}
{this.props.selectType === 'challenge' &&
window.BattleFormats[formatId]?.bestOfDefault && <p>
<label class="checkbox"><input type="checkbox" name="bestof" onChange={this.toggleCustomRule} />
<abbr title="Start a team-locked best-of-n series">
Best-of-<input
name="bestofvalue" type="number" min="3" max="9" step="2" value="3" style="width: 28px; vertical-align: initial;"
{showCustomRules && (!this.customRules ? <p>
<label class="checkbox"><input
type="checkbox" name="customrules" checked={this.customRules} onChange={this.toggleCustomRule}
/> Custom rules</label>
</p> : <fieldset>
<legend><label class="checkbox"><input
type="checkbox" name="customrules" checked={this.customRules} onChange={this.toggleCustomRule}
/> Custom rules</label></legend>
{(format?.bestOfDefault || this.bestOf) && <p>
<label class="checkbox">
<input
type="checkbox" name="bestof" checked={this.bestOf} onChange={this.toggleCustomRule}
/>
</abbr></label></p>}
{this.props.selectType === 'challenge' &&
window.BattleFormats[formatId]?.itemClauseDefault && <p>
<label class="checkbox">
<input type="checkbox" name="itemclause" onChange={this.toggleCustomRule} />
<abbr title="Start a battle with Item Clause">Item Clause</abbr></label></p>}
<abbr title="Start a team-locked best-of-n series">Best-of-<input
name="bestofvalue" type="number" min="3" max="9" step="2" value={this.bestOfValue}
onInput={this.changeBestOfValue}
style="width: 28px; vertical-align: initial;"
/></abbr></label>
</p>}
{(format?.teraPreviewDefault || this.teraPreview) && <p>
<label class="checkbox"><input
type="checkbox" name="terapreview" checked={this.teraPreview} onChange={this.toggleCustomRule}
/> Tera Type Preview</label>
</p>}
{(format?.itemClauseDefault || this.itemClause) && <p>
<label class="checkbox"><input
type="checkbox" name="itemclause" checked={this.itemClause} onChange={this.toggleCustomRule}
/> Item Clause</label>
</p>}
<textarea
name="customrules" class="textbox" rows={3} placeholder="Rules separated by commas or lines"
value={this.customRuleText} onInput={this.changeCustomRules}
style="width: 100%; box-sizing: border-box; resize: none; field-sizing: content; min-height: 3em;"
/>
<small><a
href="https://github.com/smogon/pokemon-showdown/blob/master/config/CUSTOM-RULES.md" target="_blank"
>Custom rules guide</a></small>
</fieldset>)}
<p>{this.props.children}</p>
</form>;
}

View File

@@ -656,6 +656,8 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
renderTeamPane() {
const room = this.props.room;
/** the null team is for a placeholder for a possible team being dragged
* in from the computer, or an undelete button for a deleted team */
let teams: (Team | null)[] = PS.teams.list.slice();
let isDragging = false;
if (PS.dragging?.type === 'team' && typeof PS.dragging.team === 'number') {
@@ -680,6 +682,7 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
}
const filteredTeams = this.visibleTeams(teams);
const filteredTeamCount = filteredTeams.filter(Boolean).length;
if (room.exportMode) {
return <div class="teampane">
@@ -706,7 +709,7 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
{window.TeamEditorState && TeamEditorState.renderClipboard(this.cancelClipboard)}
{filterFolder ? (
<h2>
<i class="fa fa-folder-open" aria-hidden></i> {filterFolder} {}
<i class="fa fa-folder-open" aria-hidden></i> {filterFolder} <small>({filteredTeamCount})</small>
<button class="button small" style="margin-left:5px" onClick={this.renameFolder}>
<i class="fa fa-pencil" aria-hidden></i> Rename
</button> {}
@@ -717,7 +720,7 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
) : filterFolder === '' ? (
<h2><i class="fa fa-folder-open-o" aria-hidden></i> Teams not in any folders</h2>
) : filterFormat ? (
<h2><i class="fa fa-folder-open-o" aria-hidden></i> {filterFormat} <small>({teams.length})</small></h2>
<h2><i class="fa fa-folder-open-o" aria-hidden></i> {filterFormat} <small>({filteredTeamCount})</small></h2>
) : (
<h2>All Teams <small>({teams.length})</small></h2>
)}

View File

@@ -104,7 +104,7 @@ export class PSHeader extends preact.Component {
}
return { icon, title };
}
static renderRoomTab(id: RoomID, noAria?: boolean) {
static renderRoomTab(id: RoomID, noAria?: boolean, includeMiniNotifications = true) {
const room = PS.rooms[id];
if (!room) return null;
const closable = (id === '' || id === 'rooms' ? '' : ' closable');
@@ -112,7 +112,7 @@ export class PSHeader extends preact.Component {
let notifying = room.isSubtleNotifying ? ' subtle-notifying' : '';
let hoverTitle = '';
let notifications = room.notifications;
if (id === '') {
if (id === '' && includeMiniNotifications) {
for (const roomid of PS.miniRoomList) {
const miniNotifications = PS.rooms[roomid]?.notifications;
if (miniNotifications?.length) notifications = [...notifications, ...miniNotifications];
@@ -152,6 +152,9 @@ export class PSHeader extends preact.Component {
{closeButton}
</li>;
}
static notifyingMiniRoomTabs() {
return PS.miniRoomList.filter(roomid => PS.rooms[roomid]?.notifications.length);
}
handleResize = () => {
if (!this.base) return;
@@ -217,6 +220,7 @@ export class PSHeader extends preact.Component {
</span>;
}
renderVertical() {
const miniRoomTabs = PSHeader.notifyingMiniRoomTabs();
return <div
id="header" class="header-vertical" role="navigation"
style={`width:${PSView.verticalHeaderWidth - 7}px`} onClick={PSView.scrollToHeader}
@@ -231,7 +235,8 @@ export class PSHeader extends preact.Component {
/>
<div class="tablist" role="tablist">
<ul>
{PSHeader.renderRoomTab(PS.leftRoomList[0])}
{PSHeader.renderRoomTab(PS.leftRoomList[0], false, false)}
{miniRoomTabs.map(roomid => PSHeader.renderRoomTab(roomid))}
</ul>
<ul>
{PS.leftRoomList.slice(1).map(roomid => PSHeader.renderRoomTab(roomid))}

View File

@@ -1075,6 +1075,22 @@ export class PSView extends preact.Component {
}
}
export class ReconnectTimer extends preact.Component {
timer: ReturnType<typeof setInterval> | 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 <small>{secs > 0 ? `(Autoreconnect in ${secs}s)` : `(Reconnecting...)`}</small>;
}
}
export function PSIcon(
props: { pokemon: string | Pokemon | ServerPokemon | Dex.PokemonSet | null } |
{ item: string | null } | { type: string, b?: boolean } | { category: string }

View File

@@ -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;

View File

@@ -409,3 +409,139 @@
background: linear-gradient(90deg, hsl(330, 65%, 45%), hsl(126, 71%, 73%), hsl(231, 100%, 86%));
border-color: linear-gradient(90deg, hsl(330, 66%, 58%), hsl(126, 79%, 65%), hsl(231, 98%, 65%));
}
.typetile, .typeicon {
color: white;
background: #68A090;
display: inline-block;
width: 45px;
height: 13px;
border-radius: 9px;
font: 9px Helvetica, Arial, sans-serif;
text-shadow: none;
text-align: center;
padding: 2px 4px 0 4px;
font-weight: bold;
text-transform: uppercase;
}
.typetile.tera, .typeicon.tera {
border-radius: 0;
}
.typeicon {
padding-left: 16px;
}
.typetile-Normal {
background-color:#9f9f9e;
}
.typetile-Fire {
background-color:#e72324;
}
.typetile-Fighting {
background-color:#f08104;
}
.typetile-Water {
background-color:#4881f0;
}
.typetile-Flying {
background-color:#81B9EF;
}
.typetile-Grass {
background-color:#3FA129;
}
.typetile-Poison {
background-color:#9141CB;
}
.typetile-Electric {
background-color:#f4c220;
}
.typetile-Ground {
background-color:#915121;
}
.typetile-Psychic {
background-color:#ef3e7a;
}
.typetile-Rock {
background-color:#AFA981;
}
.typetile-Ice {
background-color:#3DCEF3;
}
.typetile-Bug {
background-color:#91A119;
}
.typetile-Dragon {
background-color:#5060E1;
}
.typetile-Ghost {
background-color:#704170;
}
.typetile-Dark {
background-color:#4f3f3c;
}
.typetile-Steel {
background-color:#60A1B8;
}
.typetile-Fairy {
background-color:#EF70EF;
}
.typetile-Stellar {
background: linear-gradient(90deg, hsl(330, 66%, 58%), hsl(126, 79%, 45%) 40%, hsl(231, 98%, 65%));
}
.typeicon-Normal {
background: scroll #9FA19F url(../sprites/typeicons/Normal.png) no-repeat 2px center / 16px;
}
.typeicon-Fire {
background: scroll #E62829 url(../sprites/typeicons/Fire.png) no-repeat 2px center / 16px;
}
.typeicon-Fighting {
background: scroll #FF8000 url(../sprites/typeicons/Fighting.png) no-repeat 2px center / 16px;
}
.typeicon-Water {
background: scroll #2980EF url(../sprites/typeicons/Water.png) no-repeat 2px center / 16px;
}
.typeicon-Flying {
background: scroll #81B9EF url(../sprites/typeicons/Flying.png) no-repeat 2px center / 16px;
}
.typeicon-Grass {
background: scroll #3FA129 url(../sprites/typeicons/Grass.png) no-repeat 2px center / 16px;
}
.typeicon-Poison {
background: scroll #9141CB url(../sprites/typeicons/Poison.png) no-repeat 2px center / 16px;
}
.typeicon-Electric {
background: scroll #FAC000 url(../sprites/typeicons/Electric.png) no-repeat 2px center / 16px;
}
.typeicon-Ground {
background: scroll #915121 url(../sprites/typeicons/Ground.png) no-repeat 2px center / 16px;
}
.typeicon-Psychic {
background: scroll #EF4179 url(../sprites/typeicons/Psychic.png) no-repeat 2px center / 16px;
}
.typeicon-Rock {
background: scroll #AFA981 url(../sprites/typeicons/Rock.png) no-repeat 2px center / 16px;
}
.typeicon-Ice {
background: scroll #3DCEF3 url(../sprites/typeicons/Ice.png) no-repeat 2px center / 16px;
}
.typeicon-Bug {
background: scroll #91A119 url(../sprites/typeicons/Bug.png) no-repeat 2px center / 16px;
}
.typeicon-Dragon {
background: scroll #5060E1 url(../sprites/typeicons/Dragon.png) no-repeat 2px center / 16px;
}
.typeicon-Ghost {
background: scroll #704170 url(../sprites/typeicons/Ghost.png) no-repeat 2px center / 16px;
}
.typeicon-Dark {
background: scroll #624D4E url(../sprites/typeicons/Dark.png) no-repeat 2px center / 16px;
}
.typeicon-Steel {
background: scroll #60A1B8 url(../sprites/typeicons/Steel.png) no-repeat 2px center / 16px;
}
.typeicon-Fairy {
background: scroll #EF70EF url(../sprites/typeicons/Fairy.png) no-repeat 2px center / 16px;
}
.typeicon-Stellar {
background: linear-gradient(90deg, hsl(330, 66%, 58%), hsl(126, 79%, 45%) 40%, hsl(231, 98%, 65%)), scroll url(../sprites/typeicons/Stellar.png) no-repeat 2px center / 16px;
}

View File

@@ -17,7 +17,7 @@ $newsCache = '.var_export($GLOBALS['newsCache'], true).';
');
date_default_timezone_set('America/Los_Angeles');
$indexData = file_get_contents('../../play.pokemonshowdown.com/index.html');
$indexData = file_get_contents('../../play.pokemonshowdown.com/caches/index-old.html');
$indexData = preg_replace('/ <div class="pm-log" style="max-height:none">
.*?
<\/div>
@@ -26,7 +26,18 @@ $newsCache = '.var_export($GLOBALS['newsCache'], true).';
</div>
', $indexData, 1);
$indexData = preg_replace('/ data-newsid="[^"]*">/', ' data-newsid="'.getNewsId().'">', $indexData, 1);
file_put_contents('../../play.pokemonshowdown.com/index.html', $indexData);
file_put_contents('../../play.pokemonshowdown.com/caches/index-old.html', $indexData);
$indexData = file_get_contents('../../play.pokemonshowdown.com/caches/index-new.html');
$indexData = preg_replace('/ <div class="readable-bg">
.*?
<\/div>
/', ' <div class="readable-bg">
'.renderNews().'
</div>
', $indexData, 1);
$indexData = preg_replace('/ data-newsid="[^"]*">/', ' data-newsid="'.getNewsId().'">', $indexData, 1);
file_put_contents('../../play.pokemonshowdown.com/caches/index-new.html', $indexData);
}
include '../style/wrapper.inc.php';