diff --git a/.gitignore b/.gitignore
index 67be0bcd1..dc5b2b1f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,8 @@ package-lock.json
/website/images/
/website/ads.txt
-/DH2/*
\ No newline at end of file
+/DH2/*
+/DH2/
+/website/replays/index.html
+/website/replays/js/
+node_modules
diff --git a/.htaccess b/.htaccess
index 6852f5435..6bedf899f 100644
--- a/.htaccess
+++ b/.htaccess
@@ -21,7 +21,7 @@ RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
RewriteCond %{QUERY_STRING} !^insecure [NC]
RewriteRule ^([A-Za-z0-9-]*)$ https://play.pokemonshowdown.com/$1 [R=307,NE,L]
-# basic stuff
+# redirects
RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
RewriteRule ^appeals?\/?$ https://play.pokemonshowdown.com/view-help-request--appeal [R=302,L]
RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
@@ -55,6 +55,8 @@ RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
RewriteRule ^insecure\/?$ http://play.pokemonshowdown.com/?insecure [R=302,L]
RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
RewriteRule ^devdiscord\/?$ https://discord.com/invite/D8QwhsH [R=302,L]
+RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$ [NC]
+RewriteRule ^formatsuggestions/?$ https://pokemonshowdown.com/pages/formatsuggestions [R=302,L]
# rewrite old sprite directories/files
RewriteRule ^sprites\/xyani(.*)?$ sprites/ani$1 [L,QSA]
@@ -121,16 +123,16 @@ Header set Pragma "no-cache" env=INDEX_PAGE
Header set Expires "0" env=INDEX_PAGE
# No direct linking to the lobby.
-RewriteCond %{ENV:SCRIPT_URL} ^/(lobby/?)?$
-RewriteCond %{HTTP_REFERER} !^$
-RewriteCond %{HTTP_REFERER} !^https?:\/\/([a-z0-9-]+.)?(pokemonshowdown\.com|appjs)
-RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$
-RewriteCond %{REMOTE_ADDR} !=127.0.0.1
-RewriteCond %{REMOTE_ADDR} !=162.243.13.96
-RewriteCond %{HTTP:CF-Connecting-IP} !=173.252.196.254
-RewriteCond %{HTTP:CF-Connecting-IP} !=198.27.67.31
-RewriteCond %{HTTP:CF-Connecting-IP} !=162.243.13.96
-RewriteRule ^.* https://pokemonshowdown.com/ [R=303,L]
+# RewriteCond %{ENV:SCRIPT_URL} ^/(lobby/?)?$
+# RewriteCond %{HTTP_REFERER} !^$
+# RewriteCond %{HTTP_REFERER} !^https?:\/\/([a-z0-9-]+.)?(pokemonshowdown\.com|# appjs)
+# RewriteCond %{HTTP_HOST} ^play\.pokemonshowdown\.com$
+# RewriteCond %{REMOTE_ADDR} !=127.0.0.1
+# RewriteCond %{REMOTE_ADDR} !=162.243.13.96
+# RewriteCond %{HTTP:CF-Connecting-IP} !=173.252.196.254
+# RewriteCond %{HTTP:CF-Connecting-IP} !=198.27.67.31
+# RewriteCond %{HTTP:CF-Connecting-IP} !=162.243.13.96
+# RewriteRule ^.* https://pokemonshowdown.com/ [R=303,L]
AddType 'text/plain; charset=UTF-8' json5
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..a88115149
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,13 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "build",
+ "group": "build",
+ "problemMatcher": [],
+ "label": "npm: build",
+ "detail": "node build"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/build-tools/update b/build-tools/update
index 14ec20694..bca0f5ca0 100755
--- a/build-tools/update
+++ b/build-tools/update
@@ -101,7 +101,7 @@ if (process.argv[2] === 'full') {
compiledFiles += compiler.compileToDir(`src`, `js`, compileOpts);
-compiledFiles += compiler.compileToDir(`src`, `js`, compileOpts);
+compiledFiles += compiler.compileToDir(`website/replays/src`, `website/replays/js`, compileOpts);
compiledFiles += compiler.compileToFile(
['src/battle-dex.ts', 'src/battle-dex-data.ts', 'src/battle-log.ts', 'src/battle-log-misc.js', 'data/pokemon-showdown/server/chat-formatter.ts', 'data/text.js', 'src/battle-text-parser.ts'],
@@ -128,7 +128,7 @@ console.log(
process.stdout.write("Updating cachebuster and URLs... ");
-const URL_REGEX = /(src|href)="\/(.*?)(\?[a-z0-9]*?)?"/g;
+const URL_REGEX = /(src|href)="(.*?)(\?[a-z0-9]*?)?"/g;
function updateURL(a, b, c, d) {
c = c.replace('/replay.pokemonshowdown.com/', '/' + routes.replays + '/');
@@ -137,16 +137,27 @@ function updateURL(a, b, c, d) {
c = c.replace('/pokemonshowdown.com/', '/' + routes.root + '/');
if (d) {
- let hash = Math.random(); // just in case creating the hash fails
- try {
- const filename = c.replace('/' + routes.client + '/', '');
- const fstr = fs.readFileSync(filename, {encoding: 'utf8'});
- hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8);
- } catch (e) {}
-
- return b + '="/' + c + '?' + hash + '"';
+ if (c.startsWith('/')) {
+ let hash = Math.random(); // just in case creating the hash fails
+ try {
+ const filename = c.slice(1).replace('/' + routes.client + '/', '');
+ const fstr = fs.readFileSync(filename, {encoding: 'utf8'});
+ hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8);
+ } catch (e) {}
+
+ return b + '="' + c + '?' + hash + '"';
+ } else {
+ // hardcoded to Replays rn; TODO: generalize
+ let hash;
+ try {
+ const fstr = fs.readFileSync('website/replays/' + c, {encoding: 'utf8'});
+ hash = crypto.createHash('md5').update(fstr).digest('hex').substr(0, 8);
+ } catch (e) {}
+
+ return b + '="' + c + '?' + (hash || 'v1') + '"';
+ }
} else {
- return b + '="/' + c + '"';
+ return b + '="' + c + '"';
}
}
@@ -156,6 +167,11 @@ function writeFiles(indexContents, preactIndexContents, crossprotocolContents, r
fs.writeFileSync('crossprotocol.html', crossprotocolContents);
process.stdout.write("Writing replay-embed.js... ");
fs.writeFileSync('js/replay-embed.js', replayEmbedContents);
+
+ let replaysContents = fs.readFileSync('website/replays/index.template.html', {encoding: 'utf8'});
+ replaysContents = replaysContents.replace(URL_REGEX, updateURL);
+ fs.writeFileSync('website/replays/index.html', replaysContents);
+
console.log("DONE");
}
diff --git a/favicon-16.png b/favicon-16.png
index 19eabf169..01634f7fa 100644
Binary files a/favicon-16.png and b/favicon-16.png differ
diff --git a/favicon-256.png b/favicon-256.png
index 99cd58833..00fe674b8 100644
Binary files a/favicon-256.png and b/favicon-256.png differ
diff --git a/favicon-32.png b/favicon-32.png
index 3924e60d4..50cdf4d62 100644
Binary files a/favicon-32.png and b/favicon-32.png differ
diff --git a/favicon-notify.ico b/favicon-notify.ico
index ab9376f5a..58594f813 100644
Binary files a/favicon-notify.ico and b/favicon-notify.ico differ
diff --git a/favicon.ico b/favicon.ico
index d103d50c7..cf7439e08 100644
Binary files a/favicon.ico and b/favicon.ico differ
diff --git a/fx/bg-gen1-spl.png b/fx/bg-gen1-spl.png
index b95df9610..94d57a064 100644
Binary files a/fx/bg-gen1-spl.png and b/fx/bg-gen1-spl.png differ
diff --git a/fx/bg-gen1.png b/fx/bg-gen1.png
index fe9c46f50..bb85bc1f6 100644
Binary files a/fx/bg-gen1.png and b/fx/bg-gen1.png differ
diff --git a/js/client-mainmenu.js b/js/client-mainmenu.js
index d83c7f88c..a992382ae 100644
--- a/js/client-mainmenu.js
+++ b/js/client-mainmenu.js
@@ -37,7 +37,7 @@
} else {
buf += '
Pokémon Showdown is offline due to technical difficulties!
';
}
- buf += ' Bear with us as we freak out.';
+ buf += ' Bear with us as we freak out.';
buf += '(We\'ll be back up in a few hours.)
';
buf += '';
} else {
@@ -1224,27 +1224,73 @@
});
var FormatPopup = this.FormatPopup = this.Popup.extend({
+ events: {
+ 'keyup input[name=search]': 'updateSearch',
+ 'click details': 'updateOpen',
+ 'click i.fa': 'updateStar',
+ },
initialize: function (data) {
- var curFormat = data.format;
+ this.data = data;
+ if (!this.open) {
+ // todo: maybe make this configurable? not sure since it will cache what users toggle.
+ // avoiding that decision for now because it requires either an ugly hack
+ // or an overhaul of BattleFormats.
+ this.open = Storage.prefs('openformats') || {
+ "S/V Singles": true, "S/V Doubles": true, "National Dex": true, "OM of the Month": true,
+ "Other Metagames": true, "Randomized Format Spotlight": true, "RoA Spotlight": true,
+ };
+ }
+ if (!this.starred) this.starred = Storage.prefs('starredformats') || {};
+ if (!this.search) this.search = "";
this.onselect = data.onselect;
- var selectType = data.selectType;
- if (!selectType) selectType = (this.sourceEl.closest('form').data('search') ? 'search' : 'challenge');
+ this.selectType = data.selectType;
+ if (!this.selectType) this.selectType = (this.sourceEl.closest('form').data('search') ? 'search' : 'challenge');
+
+
+ var html = '';
+ html += this.renderFormats();
+ html += '';
+ this.$el.html(html);
+ },
+ renderFormats: function () {
+ var data = this.data;
+ var curFormat = data.format;
var bufs = [];
var curBuf = 0;
- if (selectType === 'watch') {
+ if (this.selectType === 'watch' && !this.search) {
bufs[1] = '';
}
+
+ for (var i in this.starred) {
+ if (!bufs[1]) bufs[1] = '';
+ var format = BattleFormats[i];
+ if (!format) {
+ delete this.starred[i];
+ continue;
+ }
+ if (!this.shouldDisplayFormat(format)) continue;
+ if (this.search && !i.includes(toID(this.search))) {
+ continue;
+ }
+ //
+ var formatName = BattleLog.escapeFormat(BattleFormats[i].id);
+ bufs[1] += (
+ ''
+ );
+ }
+
var curSection = '';
for (var i in BattleFormats) {
var format = BattleFormats[i];
- if (selectType === 'teambuilder') {
- if (!format.isTeambuilderFormat) continue;
- } else {
- if (format.effectType !== 'Format' || format.battleFormat) continue;
- if (selectType != 'watch' && !format[selectType + 'Show']) continue;
- }
+ if (!this.shouldDisplayFormat(format)) continue;
+ if (this.search && !format.id.includes(toID(this.search))) continue;
+ if (this.starred[i]) continue; // only show it in the starred section
if (format.section && format.section !== curSection) {
+ if (curSection) bufs[curBuf] += '';
curSection = format.section;
if (!app.supports['formatColumns']) {
curBuf = (curSection === 'Doubles' || curSection === 'Past Generations') ? 2 : 1;
@@ -1254,7 +1300,10 @@
if (!bufs[curBuf]) {
bufs[curBuf] = '';
}
- bufs[curBuf] += '' + BattleLog.escapeHTML(curSection) + '
';
+ var open = (this.open[curSection] || toID(this.search)) ? ' open' : '';
+ bufs[curBuf] += '';
+ bufs[curBuf] += '';
+ bufs[curBuf] += BattleLog.escapeHTML(curSection) + '
';
}
var formatName = BattleLog.escapeFormat(format.id);
if (formatName.charAt(0) !== '[') formatName = '[Gen 6] ' + formatName;
@@ -1262,23 +1311,65 @@
formatName = formatName.replace('[Gen 9 ', '[');
formatName = formatName.replace('[Gen 8 ', '[');
formatName = formatName.replace('[Gen 7 ', '[');
- bufs[curBuf] += '';
+ bufs[curBuf] += (
+ ''
+ );
}
-
var html = '';
- for (var i = 1, l = bufs.length; i < l; i++) {
- html += '';
}
- html += '';
- this.$el.html(html);
+ return html;
+ },
+ update: function () {
+ var $formatEl = this.$el.find('span[name=formats]');
+ $formatEl.empty();
+ $formatEl.html(this.renderFormats());
+ },
+ updateStar: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var format = $(ev.target).parent().attr('value');
+ if (this.starred[format]) {
+ delete this.starred[format];
+ } else {
+ this.starred[format] = true;
+ }
+ Storage.prefs('starredformats', this.starred);
+ this.update();
+ },
+ updateOpen: function (ev) {
+ var section = $(ev.currentTarget).attr('section');
+ this.open[section] = !this.open[section];
+ Storage.prefs('openformats', this.open);
+ },
+ updateSearch: function (event) {
+ this.search = $(event.currentTarget).val();
+ this.update();
+ },
+ shouldDisplayFormat: function (format) {
+ if (this.selectType === 'teambuilder') {
+ if (!format.isTeambuilderFormat) return false;
+ } else {
+ if (format.effectType !== 'Format' || format.battleFormat) return false;
+ if (this.selectType != 'watch' && !format[this.selectType + 'Show']) return false;
+ }
+ return true;
},
selectFormat: function (format) {
if (this.onselect) {
diff --git a/js/client.js b/js/client.js
index cf60ed632..9e624b278 100644
--- a/js/client.js
+++ b/js/client.js
@@ -406,6 +406,7 @@ function toId() {
// down
// if (document.location.hostname === 'play.pokemonshowdown.com') this.down = true;
+ // this.down = true;
this.addRoom('');
this.topbar = new Topbar({el: $('#header')});
diff --git a/js/replay-embed.template.js b/js/replay-embed.template.js
index b037fec05..15c533be9 100644
--- a/js/replay-embed.template.js
+++ b/js/replay-embed.template.js
@@ -82,6 +82,7 @@ var Replays = {
log: log.split('\n'),
isReplay: true,
paused: true,
+ autoresize: true,
});
this.$('.replay-controls-2').html(' Speed: ');
@@ -210,3 +211,12 @@ var Replays = {
window.onload = function () {
Replays.init();
};
+
+if (window.matchMedia) {
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ document.body.className = 'dark';
+ }
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (event) {
+ document.body.className = event.matches ? "dark" : "";
+ });
+}
diff --git a/js/storage.js b/js/storage.js
index 30ccd2a54..5192a126d 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -585,6 +585,35 @@ Storage.loadTeams = function () {
} catch (e) {}
};
+/** returns false to add the team, true to not add it, 'rename' to add it under a diff name */
+Storage.compareTeams = function (serverTeam, localTeam) {
+ // if titles match exactly and mons are the same, assume they're the same team
+ // if they don't match, it might be edited, but we'll go ahead and add it to the user's
+ // teambuilder since they may want that old version around. just go ahead and edit the name
+ var mons = serverTeam.team.split(',');
+ var matches = 0;
+ var otherMons = Storage.unpackTeam(localTeam.team);
+ for (var i = 0; i < mons.length; i++) {
+ for (var j = 0; j < otherMons.length; j++) {
+ if (toID(otherMons[j].species) === toID(mons[i])) {
+ matches++;
+ }
+ }
+ }
+ var sanitize = function (name) {
+ return (name || "").replace(/\s+\(server version\)/g, '').trim();
+ };
+ var nameMatches = sanitize(serverTeam.name) === sanitize(localTeam.name);
+ if (!(nameMatches && serverTeam.format === localTeam.format)) {
+ return false;
+ }
+ // if it's been edited since, invalidate the team id on this one (count it as new)
+ // and load from server
+ if (mons.length !== otherMons.length || matches !== otherMons.length) return 'rename';
+ if (serverTeam.teamid === localTeam.teamid && localTeam.teamid) return true;
+ return true;
+};
+
Storage.loadRemoteTeams = function (after) {
$.get(app.user.getActionPHP(), {act: 'getteams'}, Storage.safeJSON(function (data) {
if (data.actionerror) {
@@ -595,12 +624,19 @@ Storage.loadRemoteTeams = function (after) {
var matched = false;
for (var j = 0; j < Storage.teams.length; j++) {
var curTeam = Storage.teams[j];
- if (curTeam.teamid === team.teamid) {
+ var match = Storage.compareTeams(team, curTeam);
+ if (match === true) {
// prioritize locally saved teams over remote
// as so to not overwrite changes
matched = true;
break;
}
+ if (match === 'rename') {
+ delete curTeam.teamid;
+ if (!team.name.endsWith(' (server version)')) {
+ team.name += ' (server version)';
+ }
+ }
}
team.loaded = false;
if (!matched) {
diff --git a/replays/battle.log.php b/replays/battle.log.php
index b9e4e46dc..5b5aeb615 100644
--- a/replays/battle.log.php
+++ b/replays/battle.log.php
@@ -56,6 +56,9 @@
}
if (isset($_REQUEST['json'])) {
+ $matchSuccess = preg_match('/\\n\\|tier\\|([^|]*)\\n/', $replay['log'], $matches);
+ if ($matchSuccess) $replay['format'] = $matches[1];
+
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
die(json_encode($replay));
diff --git a/src/battle-dex-search.ts b/src/battle-dex-search.ts
index db9b1602c..80bdab33a 100644
--- a/src/battle-dex-search.ts
+++ b/src/battle-dex-search.ts
@@ -1673,8 +1673,8 @@ class BattleMoveSearch extends BattleTypedSearch<'move'> {
const isSTABmons = (format.includes('stabmons') || format.includes('stylemons')|| format === 'staaabmons');
const isTradebacks = (format.includes('tradebacks') || this.mod === 'gen1expansionpack' || this.mod === 'gen1burgundy');
const regionBornLegality = dex.gen >= 6 &&
- /^battle(spot|stadium|festival)/.test(format) || format.startsWith('vgc') ||
- (dex.gen === 9 && this.formatType !== 'natdex');
+ (/^battle(spot|stadium|festival)/.test(format) || format.startsWith('vgc') ||
+ (dex.gen === 9 && this.formatType !== 'natdex'));
// Hoenn Gaiden Baton Pass Gaiden Declaration
const isHoennGaiden = this.modFormat === 'gen3hoenngaiden' || this.modFormat.endsWith('hoenngaiden');
diff --git a/src/battle.ts b/src/battle.ts
index c8669997b..c98cfa00e 100644
--- a/src/battle.ts
+++ b/src/battle.ts
@@ -1195,10 +1195,12 @@ export class Battle {
const scale = (width / 640);
this.scene.$frame?.css('transform', 'scale(' + scale + ')');
this.scene.$frame?.css('transform-origin', 'top left');
+ this.scene.$frame?.css('margin-bottom', '' + (360 * scale - 360) + 'px');
// this.$foeHint.css('transform', 'scale(' + scale + ')');
} else {
this.scene.$frame?.css('transform', 'none');
// this.$foeHint.css('transform', 'none');
+ this.scene.$frame?.css('margin-bottom', '0');
}
};
diff --git a/style/battle-log.css b/style/battle-log.css
index d8f1a4af2..9663422bd 100644
--- a/style/battle-log.css
+++ b/style/battle-log.css
@@ -55,6 +55,7 @@
button, summary {
cursor: pointer;
+ touch-action: manipulation;
}
.button {
outline: none;
diff --git a/style/battle.css b/style/battle.css
index 5f0ddb04c..81a20d86a 100644
--- a/style/battle.css
+++ b/style/battle.css
@@ -569,8 +569,9 @@ License: GPLv2
display: block;
position: absolute;
top: 0; left: 0; bottom: 0; right: 0;
- background: #FFFFFF;
+ background: #CCCCCC;
color: #000000;
+ font-size: 14px;
}
.seeking strong {
display: block;
diff --git a/style/client.css b/style/client.css
index e4d4f62c7..e059dfbae 100644
--- a/style/client.css
+++ b/style/client.css
@@ -486,6 +486,17 @@ select {
content: "\f0e6";
}
+i.subtle {
+ opacity: 0.25;
+ filter: alpha(opacity=25);
+}
+
+i.subtle:hover {
+ opacity: 1.0;
+ filter: alpha(opacity=100);
+}
+
+
.tabbar li,
.tabbar ul {
display: block;
diff --git a/website/.htaccess b/website/.htaccess
index 1b7ecc215..58266a864 100644
--- a/website/.htaccess
+++ b/website/.htaccess
@@ -11,9 +11,9 @@ allow from all
order deny,allow
deny from all
-#AuthName pokemonshowdown.com
-#AuthUserFile /home/pokemon/public_html/_vti_pvt/service.pwd
-#AuthGroupFile /home/pokemon/public_html/_vti_pvt/service.grp
+# AuthName pokemonshowdown.com
+# AuthUserFile /home/pokemon/public_html/_vti_pvt/service.pwd
+# AuthGroupFile /home/pokemon/public_html/_vti_pvt/service.grp
AddType text/plain .phps
AddType application/octet-stream .heapsnapshot
@@ -35,7 +35,7 @@ RewriteRule ^(.*)$ https://pokemonshowdown.com/$1 [R=302,NE,L]
RewriteCond %{HTTP_HOST} ^(www\.)?forum\.pokemonshowdown\.com$ [NC]
RewriteRule ^(.*)$ https://pokemonshowdown.com/forums/ [R=301,NE,L]
-RewriteCond %{HTTP_HOST} !^pokemonshowdown\.com$
+RewriteCond %{HTTP_HOST} ^www\.pokemonshowdown\.com$
RewriteRule ^(.*)$ https://pokemonshowdown.com/$1 [R=301,NE,L]
RewriteRule ^secure$ ./ [L,QSA]
@@ -77,6 +77,8 @@ RewriteRule ^servers\/([A-Za-z0-9-]+)\.json$ servers/server.php?id=$1&json [L,QS
# RewriteRule ^replay\/?search/?$ replay/search.php [L,QSA]
# RewriteRule ^replay\/?([A-Za-z0-9-]+)$ replay/battle.php?name=$1 [L,QSA]
+RewriteRule ^replays/([a-z0-9-]+)$ replays/index.html [L,QSA]
+
RewriteRule ^replay\/(.*)$ https://replay.pokemonshowdown.com/$1 [R=302,L]
RewriteRule ^dex\/(.*)$ https://dex.pokemonshowdown.com/$1 [R=302,L]
RewriteRule ^pokedex\/(.*)$ https://dex.pokemonshowdown.com/$1 [R=302,L]
diff --git a/website/favicon.ico b/website/favicon.ico
index 34b1536b1..cf7439e08 100644
Binary files a/website/favicon.ico and b/website/favicon.ico differ
diff --git a/website/lib/serverlist.inc.php b/website/lib/serverlist.inc.php
index 0fb0c7fa6..d6978a2d6 100644
--- a/website/lib/serverlist.inc.php
+++ b/website/lib/serverlist.inc.php
@@ -59,7 +59,7 @@ function servercmp($a, $b) {
$more = true;
}
?>
- ',$server['name'],'
(official server)'; else echo $server['name']; ?>
+ ',$server['name'],'
(official server)'; else echo $server['name']; ?>
', $summary);
$summary = str_replace("[b]", '', $summary);
$summary = str_replace("[/b]", '', $summary);
+ $summary = str_replace("[i]", '', $summary);
+ $summary = str_replace("[/i]", '', $summary);
$summary = ''.$summary.'
';
$time = ''.time();
diff --git a/website/replays/index.template.html b/website/replays/index.template.html
new file mode 100644
index 000000000..395a2c7df
--- /dev/null
+++ b/website/replays/index.template.html
@@ -0,0 +1,101 @@
+
+
+
+
+Replays - Pokémon Showdown!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/replays/replay-api.php b/website/replays/replay-api.php
new file mode 100644
index 000000000..1a8693c8e
--- /dev/null
+++ b/website/replays/replay-api.php
@@ -0,0 +1,7 @@
+isSysop() ? '1' : '';
diff --git a/website/replays/search.json.php b/website/replays/search.json.php
new file mode 100644
index 000000000..c714bea56
--- /dev/null
+++ b/website/replays/search.json.php
@@ -0,0 +1,51 @@
+toID($username);
+$isPrivateAllowed = false;
+if ($isPrivate) {
+ header('HTTP/1.1 400 Bad Request');
+ die('"ERROR: you cannot access private replays with the public API"');
+}
+
+$page = intval($_REQUEST['page'] ?? 0);
+
+$replays = null;
+if ($page > 25) {
+ die('"ERROR: page limit is 25"');
+} else if ($username || $format) {
+ $replays = $Replays->search([
+ "username" => $username,
+ "username2" => $username2,
+ "format" => $format,
+ "byRating" => $byRating,
+ "isPrivate" => $isPrivate,
+ "page" => $page
+ ]);
+} else if ($contains) {
+ $replays = $Replays->fullSearch($contains, $page);
+} else {
+ $replays = $Replays->recent();
+}
+
+if ($replays) {
+ foreach ($replays as &$replay) {
+ if ($replay['password'] ?? null) {
+ $replay['id'] .= '-' . $replay['password'];
+ }
+ unset($replay['password']);
+ }
+}
+
+echo json_encode($replays);
diff --git a/website/replays/search.notjson.php b/website/replays/search.notjson.php
new file mode 100644
index 000000000..83f72ab90
--- /dev/null
+++ b/website/replays/search.notjson.php
@@ -0,0 +1,51 @@
+userid($username);
+$isPrivateAllowed = ($username === $curuser['userid'] || $users->isSysop());
+if ($isPrivate && !$isPrivateAllowed) {
+ header('HTTP/1.1 403 Forbidden');
+ die('"ERROR: access denied"');
+}
+
+$page = intval($_REQUEST['page'] ?? 0);
+
+$replays = null;
+if ($page > 25) {
+ die('"ERROR: page limit is 25"');
+} else if ($username || $format) {
+ $replays = $Replays->search([
+ "username" => $username,
+ "username2" => $username2,
+ "format" => $format,
+ "byRating" => $byRating,
+ "isPrivate" => $isPrivate,
+ "page" => $page
+ ]);
+} else if ($contains) {
+ $replays = $Replays->fullSearch($contains, $page);
+} else {
+ $replays = $Replays->recent();
+}
+
+if ($replays) {
+ foreach ($replays as &$replay) {
+ if ($replay['password'] ?? null) {
+ $replay['id'] .= '-' . $replay['password'];
+ }
+ unset($replay['password']);
+ }
+}
+
+echo ']' . json_encode($replays);
diff --git a/website/replays/src/replays.tsx b/website/replays/src/replays.tsx
new file mode 100644
index 000000000..c076004e1
--- /dev/null
+++ b/website/replays/src/replays.tsx
@@ -0,0 +1,463 @@
+/** @jsx preact.h */
+import preact from 'preact';
+import {Net} from './utils';
+import {Battle} from '../../../src/battle';
+import {BattleSound} from '../../../src/battle-sound';
+import $ from 'jquery';
+
+function showAd(id: string) {
+ // @ts-expect-error
+ window.top.__vm_add = window.top.__vm_add || [];
+
+ //this is a x-browser way to make sure content has loaded.
+
+ (function (success) {
+ if (window.document.readyState !== "loading") {
+ success();
+ } else {
+ window.document.addEventListener("DOMContentLoaded", function () {
+ success();
+ });
+ }
+ })(function () {
+ var placement = document.createElement("div");
+ placement.setAttribute("class", "vm-placement");
+ if (window.innerWidth > 1000) {
+ //load desktop placement
+ placement.setAttribute("data-id", "6452680c0b35755a3f09b59b");
+ } else {
+ //load mobile placement
+ placement.setAttribute("data-id", "645268557bc7b571c2f06f62");
+ }
+ document.querySelector("#" + id)!.appendChild(placement);
+ // @ts-expect-error
+ window.top.__vm_add.push(placement);
+ });
+}
+
+class SearchPanel extends preact.Component {
+ results: {
+ uploadtime: number;
+ id: string;
+ format: string;
+ p1: string;
+ p2: string;
+ }[] | null = null;
+ format = '';
+ user = '';
+ sort = 'date';
+ moreFun = false;
+ moreCompetitive = false;
+ override componentDidMount() {
+ Net('https://replay.pokemonshowdown.com/search.json').get().then(result => {
+ this.results = JSON.parse(result);
+ this.forceUpdate();
+ });
+ }
+ search(format: string, user: string) {
+ this.format = format;
+ this.user = user;
+ this.results = null;
+ this.moreFun = false;
+ this.moreCompetitive = false;
+ this.forceUpdate();
+ Net('https://replay.pokemonshowdown.com/search.json').get({
+ query: {user: this.user, format: this.format},
+ }).then(result => {
+ this.results = JSON.parse(result);
+ this.forceUpdate();
+ });
+ }
+ submitForm = (e: Event) => {
+ e.preventDefault();
+ // @ts-expect-error
+ const format = document.getElementsByName('format')[0]?.value || '';
+ // @ts-expect-error
+ const user = document.getElementsByName('user')[0]?.value || '';
+ this.search(format, user);
+ };
+ cancelForm = (e: Event) => {
+ e.preventDefault();
+ this.search('', '');
+ };
+ showMoreFun = (e: Event) => {
+ e.preventDefault();
+ this.moreFun = true;
+ this.forceUpdate();
+ };
+ showMoreCompetitive = (e: Event) => {
+ e.preventDefault();
+ this.moreCompetitive = true;
+ this.forceUpdate();
+ };
+ override render() {
+ const searchResults = ;
+ const activelySearching = !!(this.format || this.user);
+ return {!activelySearching &&
}{!activelySearching &&
}
;
+ }
+}
+
+class BattleDiv extends preact.Component {
+ override shouldComponentUpdate() {
+ return false;
+ }
+ override render() {
+ return ;
+ }
+}
+class BattleLogDiv extends preact.Component {
+ override shouldComponentUpdate() {
+ return false;
+ }
+ override render() {
+ return ;
+ }
+}
+
+class BattlePanel extends preact.Component<{id: string}> {
+ result: {
+ uploadtime: number;
+ id: string;
+ format: string;
+ p1: string;
+ p2: string;
+ log: string;
+ views: number;
+ p1id: string;
+ p2id: string;
+ rating: number;
+ private: number;
+ password: string;
+ } | null | undefined = undefined;
+ battle: Battle;
+ speed = 'normal';
+ override componentDidMount() {
+ Net(`https://replay.pokemonshowdown.com/${this.props.id}.json`).get().then(result => {
+ const replay: NonNullable = JSON.parse(result);
+ this.result = replay;
+ const $base = $(this.base!);
+ this.battle = new Battle({
+ id: replay.id,
+ $frame: $base.find('.battle'),
+ $logFrame: $base.find('.battle-log'),
+ log: replay.log.split('\n'),
+ isReplay: true,
+ paused: true,
+ autoresize: true,
+ });
+ // for ease of debugging
+ (window as any).battle = this.battle;
+ this.battle.subscribe(_ => {
+ this.forceUpdate();
+ });
+ this.forceUpdate();
+ }).catch(_ => {
+ this.result = null;
+ this.forceUpdate();
+ });
+ showAd('LeaderboardBTF');
+ }
+ override componentWillUnmount(): void {
+ this.battle.destroy();
+ (window as any).battle = null;
+ }
+ play = () => {
+ this.battle.play();
+ };
+ replay = () => {
+ this.battle.reset();
+ this.battle.play();
+ this.forceUpdate();
+ };
+ pause = () => {
+ this.battle.pause();
+ };
+ nextTurn = () => {
+ this.battle.seekBy(1);
+ };
+ prevTurn = () => {
+ this.battle.seekBy(-1);
+ };
+ firstTurn = () => {
+ this.battle.seekTurn(0);
+ };
+ lastTurn = () => {
+ this.battle.seekTurn(Infinity);
+ };
+ goToTurn = () => {
+ const turn = prompt('Turn?');
+ if (!turn?.trim()) return;
+ let turnNum = Number(turn);
+ if (turn === 'e' || turn === 'end' || turn === 'f' || turn === 'finish') turnNum = Infinity;
+ if (isNaN(turnNum) || turnNum < 0) alert("Invalid turn");
+ this.battle.seekTurn(turnNum);
+ };
+ switchSides = () => {
+ this.battle.switchSides();
+ };
+ changeSpeed = (e: Event) => {
+ this.speed = (e.target as HTMLSelectElement).value;
+ const fadeTable = {
+ hyperfast: 40,
+ fast: 50,
+ normal: 300,
+ slow: 500,
+ reallyslow: 1000
+ };
+ const delayTable = {
+ hyperfast: 1,
+ fast: 1,
+ normal: 1,
+ slow: 1000,
+ reallyslow: 3000
+ };
+ this.battle.messageShownTime = delayTable[this.speed];
+ this.battle.messageFadeTime = fadeTable[this.speed];
+ this.battle.scene.updateAcceleration();
+ };
+ changeSound = (e: Event) => {
+ const muted = (e.target as HTMLSelectElement).value;
+ this.battle.setMute(muted === 'off');
+ };
+ renderNotFound() {
+ return
+ Not Found
+
+ The battle you're looking for has expired. Battles expire after 15 minutes of inactivity unless they're saved.
+
+
+ In the future, remember to click Upload and share replay to save a replay permanently.
+
+ ;
+ }
+ override render() {
+ const atEnd = this.battle?.atQueueEnd;
+ const atStart = !this.battle?.started;
+ if (this.result === null) return this.renderNotFound();
+ return
+
+
+
+
+ {atEnd ?
+
+ : this.battle?.paused ?
+
+ :
+
+ } {}
+
+
+
+
+
+
+ {}
+
+
+
+ {}
+
+
+
{this.result?.format}: {this.result?.p1} vs. {this.result?.p2}
+
+ Uploaded: {new Date(this.result?.uploadtime! * 1000 || 0).toDateString()}
+ {this.result?.rating ? ` | Rating: ${this.result?.rating}` : ''}
+
+
+
+
;
+ }
+}
+
+class PSReplays extends preact.Component {
+ override render() {
+ return {
+ document.location.pathname === '/replays/' ?
+ :
+ }
;
+ }
+}
+
+preact.render(, document.getElementById('main')!);
+
+if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
+ document.body.className = 'dark';
+}
+window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', event => {
+ document.body.className = event.matches ? "dark" : "";
+});
diff --git a/website/replays/src/utils.ts b/website/replays/src/utils.ts
new file mode 100644
index 000000000..38298cef4
--- /dev/null
+++ b/website/replays/src/utils.ts
@@ -0,0 +1,245 @@
+
+/**********************************************************************
+ * Polyfills
+ *********************************************************************/
+
+if (!Array.prototype.indexOf) {
+ Array.prototype.indexOf = function (searchElement, fromIndex) {
+ for (let i = (fromIndex || 0); i < this.length; i++) {
+ if (this[i] === searchElement) return i;
+ }
+ return -1;
+ };
+}
+if (!Array.prototype.includes) {
+ Array.prototype.includes = function (thing) {
+ return this.indexOf(thing) !== -1;
+ };
+}
+if (!String.prototype.includes) {
+ String.prototype.includes = function (thing) {
+ return this.indexOf(thing) !== -1;
+ };
+}
+if (!String.prototype.startsWith) {
+ String.prototype.startsWith = function (thing) {
+ return this.slice(0, thing.length) === thing;
+ };
+}
+if (!String.prototype.endsWith) {
+ String.prototype.endsWith = function (thing) {
+ return this.slice(-thing.length) === thing;
+ };
+}
+if (!String.prototype.trim) {
+ String.prototype.trim = function () {
+ return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '');
+ };
+}
+if (!Object.assign) {
+ Object.assign = function (thing: any, rest: any) {
+ for (let i = 1; i < arguments.length; i++) {
+ let source = arguments[i];
+ for (let k in source) {
+ thing[k] = source[k];
+ }
+ }
+ return thing;
+ };
+}
+if (!Object.create) {
+ Object.create = function (proto: any) {
+ function F() {}
+ F.prototype = proto;
+ return new (F as any)();
+ };
+}
+if (!window.console) {
+ // in IE8, the console object is only defined when devtools is open
+ // I don't actually know if this will cause problems when you open devtools,
+ // but that's something I can figure out if I ever bother testing in IE8
+ (window as any).console = {
+ log() {},
+ };
+}
+
+/**********************************************************************
+ * Net
+ *********************************************************************/
+
+export interface PostData {
+ [key: string]: string | number;
+}
+export interface NetRequestOptions {
+ method?: 'GET' | 'POST';
+ body?: string | PostData;
+ query?: PostData;
+}
+export class HttpError extends Error {
+ statusCode?: number;
+ body: string;
+ constructor(message: string, statusCode: number | undefined, body: string) {
+ super(message);
+ this.name = 'HttpError';
+ this.statusCode = statusCode;
+ this.body = body;
+ try {
+ (Error as any).captureStackTrace(this, HttpError);
+ } catch (err) {}
+ }
+}
+export class NetRequest {
+ uri: string;
+ constructor(uri: string) {
+ this.uri = uri;
+ }
+
+ /**
+ * Makes a basic http/https request to the URI.
+ * Returns the response data.
+ *
+ * Will throw if the response code isn't 200 OK.
+ *
+ * @param opts request opts
+ */
+ get(opts: NetRequestOptions = {}): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ let uri = this.uri;
+ if (opts.query) {
+ uri += (uri.includes('?') ? '&' : '?') + Net.encodeQuery(opts.query);
+ }
+ xhr.open(opts.method || 'GET', uri);
+ xhr.onreadystatechange = function () {
+ const DONE = 4;
+ if (xhr.readyState === DONE) {
+ if (xhr.status === 200) {
+ resolve(xhr.responseText || '');
+ return;
+ }
+ const err = new HttpError(xhr.statusText || "Connection error", xhr.status, xhr.responseText);
+ reject(err);
+ }
+ };
+ if (opts.body) {
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+ xhr.send(Net.encodeQuery(opts.body));
+ } else {
+ xhr.send();
+ }
+ });
+ }
+
+ /**
+ * Makes a http/https POST request to the given link.
+ * @param opts request opts
+ * @param body POST body
+ */
+ post(opts: Omit, body: PostData | string): Promise;
+ /**
+ * Makes a http/https POST request to the given link.
+ * @param opts request opts
+ */
+ post(opts?: NetRequestOptions): Promise;
+ post(opts: NetRequestOptions = {}, body?: PostData | string) {
+ if (!body) body = opts.body;
+ return this.get({
+ ...opts,
+ method: 'POST',
+ body,
+ });
+ }
+}
+
+export function Net(uri: string) {
+ return new NetRequest(uri);
+}
+
+Net.encodeQuery = function (data: string | PostData) {
+ if (typeof data === 'string') return data;
+ let urlencodedData = '';
+ for (const key in data) {
+ if (urlencodedData) urlencodedData += '&';
+ urlencodedData += encodeURIComponent(key) + '=' + encodeURIComponent((data as any)[key]);
+ }
+ return urlencodedData;
+};
+
+/**********************************************************************
+ * Models
+ *********************************************************************/
+
+export class PSSubscription {
+ observable: PSModel | PSStreamModel;
+ listener: (value?: any) => void;
+ constructor(observable: PSModel | PSStreamModel, listener: (value?: any) => void) {
+ this.observable = observable;
+ this.listener = listener;
+ }
+ unsubscribe() {
+ const index = this.observable.subscriptions.indexOf(this);
+ if (index >= 0) this.observable.subscriptions.splice(index, 1);
+ }
+}
+
+/**
+ * PS Models roughly implement the Observable spec. Not the entire
+ * spec - just the parts we use. PSModel just notifies subscribers of
+ * updates - a simple model for React.
+ */
+export class PSModel {
+ subscriptions = [] as PSSubscription[];
+ subscribe(listener: () => void) {
+ const subscription = new PSSubscription(this, listener);
+ this.subscriptions.push(subscription);
+ return subscription;
+ }
+ subscribeAndRun(listener: () => void) {
+ const subscription = this.subscribe(listener);
+ subscription.listener();
+ return subscription;
+ }
+ update() {
+ for (const subscription of this.subscriptions) {
+ subscription.listener();
+ }
+ }
+}
+
+/**
+ * PS Models roughly implement the Observable spec. PSStreamModel
+ * streams some data out. This is very not-React, which generally
+ * expects the DOM to be a pure function of state. Instead PSModels
+ * which hold state, PSStreamModels give state directly to views,
+ * so that the model doesn't need to hold a redundant copy of state.
+ */
+export class PSStreamModel {
+ subscriptions = [] as PSSubscription[];
+ updates = [] as T[];
+ subscribe(listener: (value: T) => void) {
+ // TypeScript bug
+ const subscription: PSSubscription = new PSSubscription(this, listener);
+ this.subscriptions.push(subscription);
+ if (this.updates.length) {
+ for (const update of this.updates) {
+ subscription.listener(update);
+ }
+ this.updates = [];
+ }
+ return subscription;
+ }
+ subscribeAndRun(listener: (value: T) => void) {
+ const subscription = this.subscribe(listener);
+ subscription.listener(null);
+ return subscription;
+ }
+ update(value: T) {
+ if (!this.subscriptions.length) {
+ // save updates for later
+ this.updates.push(value);
+ }
+ for (const subscription of this.subscriptions) {
+ subscription.listener(value);
+ }
+ }
+}
diff --git a/website/style/global.css b/website/style/global.css
index 45d4cf5d4..b3b67f12f 100644
--- a/website/style/global.css
+++ b/website/style/global.css
@@ -49,7 +49,7 @@ header {
left: 0;
top: 0;
}
-.nav a {
+.nav a, .dark .nav a {
color: white;
background: #3a4f88;
background: linear-gradient(to bottom, #4c63a3, #273661);
@@ -63,10 +63,11 @@ header {
margin-left: -1px;
font-size: 11pt;
}
-.nav a:hover {
+.nav a:hover, .dark .nav a:hover {
background: linear-gradient(to bottom, #5a77c7, #2f447f);
+ border: 1px solid #222c4a;
}
-.nav a:active {
+.nav a:active, .dark .nav a:active {
background: linear-gradient(to bottom, #273661, #4c63a3);
box-shadow: 0.5px 1px 2px rgba(255, 255, 255, 0.45), inset 0.5px 1px -1px rgba(255, 255, 255, 0.5);
}
@@ -176,6 +177,9 @@ h1 {
padding: 0 0 5px;
margin: 0;
}
+.dark h1 {
+ border-bottom-color: #888;
+}
.section {
color: black;
@@ -188,6 +192,11 @@ h1 {
backdrop-filter: blur(4px);
}
+.dark .section {
+ border-color: rgba(255, 255, 255, .4);
+ background: rgba(50, 50, 50, .5);
+ color: #DDD;
+}
/*********************************************************
* Defaults