diff --git a/.gitignore b/.gitignore
index be03edd8ec..2a507e2b85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,6 @@ package-lock.json
/website/files/
/website/images/
/website/ads.txt
+/website/replays/index.html
+/website/replays/js/
node_modules
diff --git a/build-tools/update b/build-tools/update
index 2c8e928eee..7494e9239a 100755
--- a/build-tools/update
+++ b/build-tools/update
@@ -100,7 +100,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'],
@@ -127,7 +127,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 + '/');
@@ -136,16 +136,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 + '"';
}
}
@@ -154,6 +165,11 @@ function writeFiles(indexContents, preactIndexContents, crossprotocolContents, r
fs.writeFileSync('preactalpha.html', preactIndexContents);
fs.writeFileSync('crossprotocol.html', crossprotocolContents);
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/replays/battle.log.php b/replays/battle.log.php
index b9e4e46dc1..5b5aeb6152 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/website/.htaccess b/website/.htaccess
index 96aaf8ed75..58266a864c 100644
--- a/website/.htaccess
+++ b/website/.htaccess
@@ -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/replays/index.template.html b/website/replays/index.template.html
new file mode 100644
index 0000000000..a2d3aea401
--- /dev/null
+++ b/website/replays/index.template.html
@@ -0,0 +1,93 @@
+
+
+
+
+
Replays - Pokémon Showdown!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/replays/replay-api.php b/website/replays/replay-api.php
new file mode 100644
index 0000000000..1a8693c8e9
--- /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 0000000000..c714bea56e
--- /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 0000000000..83f72ab906
--- /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 0000000000..cf3e002daf
--- /dev/null
+++ b/website/replays/src/replays.tsx
@@ -0,0 +1,337 @@
+/** @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) {
+ 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);
+ 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';
+ override componentDidMount() {
+ Net('https://replay.pokemonshowdown.com/search.json').get().then(result => {
+ this.results = JSON.parse(result);
+ this.forceUpdate();
+ });
+ }
+ submitForm = (e: Event) => {
+ e.preventDefault();
+ // @ts-ignore
+ this.format = document.getElementsByName('format')[0]?.value || '';
+ // @ts-ignore
+ this.user = document.getElementsByName('user')[0]?.value || '';
+ this.results = null;
+ 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();
+ });
+ };
+ override render() {
+ return ;
+ }
+}
+
+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 0000000000..38298cef4c
--- /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);
+ }
+ }
+}