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); + } + } +}