--- /dev/null
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AlttpSeed;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Artisan;
+
+class AlttpSeedController extends Controller
+{
+
+ public function byHash($hash) {
+ $seed = AlttpSeed::where('hash', '=', $hash)->firstOrFail();
+
+ if ($seed->race) {
+ $seed->makeHidden('seed');
+ }
+ if ($seed->mystery) {
+ $seed->makeHidden('settings');
+ }
+
+ return $seed->toJson();
+ }
+
+ public function retry($hash) {
+ $seed = AlttpSeed::where('hash', '=', $hash)->firstOrFail();
+
+ if ($seed->status == 'error') {
+ $seed->status = 'pending';
+ $seed->save();
+ Artisan::call('alttp:generate '.intval($seed->id));
+ }
+
+ return $seed->toJson();
+ }
+
+}
--- /dev/null
+/home/holy/alttp/storage/app/alttp-seeds
\ No newline at end of file
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Header from './common/Header';
+import AlttpSeed from './pages/AlttpSeed';
import Tournament from './pages/Tournament';
import User from './pages/User';
+import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext';
import UserContext from '../helpers/UserContext';
const App = () => {
}, []);
return <BrowserRouter>
- <UserContext.Provider value={user}>
- <Header doLogout={doLogout} />
- <Routes>
- <Route path="tournaments/:id" element={<Tournament />} />
- <Route path="users/:id" element={<User />} />
- <Route path="*" element={<Navigate to="/tournaments/4" />} />
- </Routes>
- </UserContext.Provider>
+ <AlttpBaseRomProvider>
+ <UserContext.Provider value={user}>
+ <Header doLogout={doLogout} />
+ <Routes>
+ <Route path="h/:hash" element={<AlttpSeed />} />
+ <Route path="tournaments/:id" element={<Tournament />} />
+ <Route path="users/:id" element={<User />} />
+ <Route path="*" element={<Navigate to="/tournaments/4" />} />
+ </Routes>
+ </UserContext.Provider>
+ </AlttpBaseRomProvider>
</BrowserRouter>;
};
--- /dev/null
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
+
+const BaseRomButton = () => {
+ const { rom, setRom } = useAlttpBaseRom();
+
+ const handleFile = React.useCallback(async e => {
+ if (e.target.files.length != 1) {
+ setRom(null);
+ } else {
+ const buf = await e.target.files[0].arrayBuffer();
+ setRom(buf);
+ }
+ }, [setRom]);
+
+ if (rom) return null;
+
+ return <span>
+ <input
+ accept=".sfc"
+ className="d-none"
+ id="alttp.baseRom"
+ onChange={handleFile}
+ type="file"
+ />
+ <label htmlFor="alttp.baseRom">
+ <Button as="span" variant="primary">
+ {i18n.t('alttp.setBaseRom')}
+ </Button>
+ </label>
+ </span>;
+};
+
+export default withTranslation()(BaseRomButton);
--- /dev/null
+import FileSaver from 'file-saver';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Container, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import BaseRomButton from './BaseRomButton';
+import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
+import BPS from '../../helpers/bps';
+import i18n from '../../i18n';
+
+const applyPatch = (rom, patch, filename) => {
+ try {
+ const bps = new BPS();
+ bps.setPatch(patch);
+ bps.setSource(rom);
+ const result = bps.applyPatch();
+ FileSaver.saveAs(new Blob([result], { type: 'application/octet-stream' }), filename);
+ } catch (e) {
+ toastr.error(i18n.t('alttpSeeds.patchError', { msg: e.message }));
+ }
+};
+
+const isDefaultSetting = () => false;
+
+const Seed = ({ onRetry, patch, seed }) => {
+ const { rom } = useAlttpBaseRom();
+
+ return <Container>
+ <h1>{i18n.t('alttpSeeds.heading')}</h1>
+ <Row>
+ <Col md={{ order: 2 }}>
+ {rom ?
+ <Button
+ disabled={!seed || seed.status !== 'generated' || !patch}
+ onClick={() => applyPatch(
+ rom,
+ patch,
+ `${i18n.t('alttpSeeds.filename', {
+ hash: seed.hash,
+ preset: seed.preset,
+ })}.sfc`,
+ )}
+ variant="primary"
+ >
+ {i18n.t(patch ? 'alttpSeeds.patch' : 'alttpSeeds.fetchingPatch')}
+ </Button>
+ :
+ <BaseRomButton />
+ }
+ </Col>
+ <Col md={{ order: 1 }}>
+ <p>
+ {i18n.t('alttpSeeds.preset')}:
+ {' '}
+ <strong>{i18n.t(`alttpSeeds.presets.${seed.preset}`)}</strong>
+ </p>
+ {seed.seed ?
+ <p>
+ {i18n.t('alttpSeeds.seed')}:
+ {' '}
+ <strong>{seed.seed}</strong>
+ </p>
+ : null}
+ {seed.race ?
+ <p>{i18n.t('alttpSeeds.race')}</p>
+ : null}
+ {seed.mystery ?
+ <p>{i18n.t('alttpSeeds.mystery')}</p>
+ : null}
+ {seed.status === 'generated' ?
+ <p>
+ {i18n.t('alttpSeeds.generated')}:
+ {' '}
+ <strong>
+ {i18n.t('alttpSeeds.date', { date: new Date(seed.updated_at) })}
+ </strong>
+ </p>
+ :
+ <p>
+ {i18n.t('alttpSeeds.status')}:
+ {' '}
+ <strong>{i18n.t(`alttpSeeds.statuses.${seed.status}`)}</strong>
+ </p>
+ }
+ {seed.status === 'error' ?
+ <p>
+ <Button
+ onClick={onRetry}
+ variant="secondary"
+ >
+ {i18n.t('button.retry')}
+ </Button>
+ </p>
+ : null}
+ </Col>
+ </Row>
+ <h2 className="mt-5">{i18n.t('alttpSeeds.generator')}</h2>
+ <p>{i18n.t(`alttpSeeds.generators.${seed.generator}`)}</p>
+ {seed.settings ? <>
+ <h2 className="mt-5">{i18n.t('alttpSeeds.settings')}</h2>
+ <Row>
+ {Object.entries(seed.settings).map(([key, value]) =>
+ <Col key={key} sm={4} md={3} lg={2} className="mb-2">
+ <small className="text-muted">
+ {i18n.t(`alttpSeeds.settingName.${key}`)}
+ </small>
+ <br />
+ {isDefaultSetting(key, value) ?
+ i18n.t(`alttpSeeds.settingValue.${key}.${value}`)
+ :
+ <strong>{i18n.t(`alttpSeeds.settingValue.${key}.${value}`)}</strong>
+ }
+ </Col>
+ )}
+ </Row>
+ </> : null}
+ </Container>;
+};
+
+Seed.propTypes = {
+ onRetry: PropTypes.func,
+ patch: PropTypes.instanceOf(ArrayBuffer),
+ seed: PropTypes.shape({
+ generator: PropTypes.string,
+ hash: PropTypes.string,
+ mystery: PropTypes.bool,
+ preset: PropTypes.string,
+ race: PropTypes.bool,
+ seed: PropTypes.string,
+ settings: PropTypes.shape({
+ }),
+ status: PropTypes.string,
+ updated_at: PropTypes.string,
+ }),
+};
+
+export default withTranslation()(Seed);
--- /dev/null
+import axios from 'axios';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import NotFound from './NotFound';
+import Seed from '../alttp-seeds/Seed';
+import ErrorBoundary from '../common/ErrorBoundary';
+import ErrorMessage from '../common/ErrorMessage';
+import Loading from '../common/Loading';
+
+const AosSeed = () => {
+ const params = useParams();
+ const { hash } = params;
+
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [patch, setPatch] = useState(null);
+ const [seed, setSeed] = useState(null);
+
+ const loadSeed = useCallback((hash, ctrl) => {
+ axios
+ .get(`/api/alttp-seed/${hash}`, { signal: ctrl.signal })
+ .then(response => {
+ setError(null);
+ setLoading(false);
+ setSeed(response.data);
+ window.document.title = response.data.hash;
+ })
+ .catch(error => {
+ setError(error);
+ setLoading(false);
+ setSeed(null);
+ });
+ }, []);
+
+ useEffect(() => {
+ setLoading(true);
+ const ctrl = new AbortController();
+ loadSeed(hash, ctrl);
+ return () => {
+ ctrl.abort();
+ };
+ }, [hash]);
+
+ useEffect(() => {
+ if (!seed || seed.status !== 'pending') {
+ return;
+ }
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => {
+ loadSeed(seed.hash, ctrl);
+ }, 2000);
+ return () => {
+ clearTimeout(timer);
+ ctrl.abort();
+ };
+ }, [seed]);
+
+ useEffect(() => {
+ setPatch(null);
+ if (!seed || seed.status !== 'generated') {
+ return;
+ }
+ const ctrl = new AbortController();
+ axios
+ .get(`/alttp-seeds/${hash}.bps`, {
+ responseType: 'arraybuffer',
+ signal: ctrl.signal,
+ })
+ .then(response => {
+ setPatch(response.data);
+ })
+ .catch(error => {
+ setError(error);
+ });
+ return () => {
+ ctrl.abort();
+ };
+ }, [hash, seed]);
+
+ const retry = useCallback(async () => {
+ await axios.post(`/api/alttp-seed/${hash}/retry`);
+ setSeed(seed => ({ ...seed, status: 'pending' }));
+ });
+
+ if (loading) {
+ return <Loading />;
+ }
+
+ if (error) {
+ return <ErrorMessage error={error} />;
+ }
+
+ if (!seed) {
+ return <NotFound />;
+ }
+
+ return <ErrorBoundary>
+ <Seed onRetry={retry} patch={patch} seed={seed} />
+ </ErrorBoundary>;
+};
+
+export default AosSeed;
--- /dev/null
+import CRC32 from 'crc-32';
+import localforage from 'localforage';
+import PropTypes from 'prop-types';
+import React from 'react';
+import toastr from 'toastr';
+
+import i18n from '../i18n';
+
+const AlttpBaseRomContext = React.createContext(null);
+
+const AlttpBaseRomProvider = ({ children }) => {
+ const [rom, setRom] = React.useState(null);
+
+ const setRomCallback = React.useCallback(buffer => {
+ if (buffer) {
+ const crc = CRC32.buf(new Uint8Array(buffer));
+ if (crc === 0x3322EFFC) {
+ setRom(buffer);
+ localforage.setItem('alttpBaseRom', buffer);
+ toastr.success(i18n.t('alttp.baseRomSet'));
+ } else {
+ toastr.error(i18n.t('alttp.baseRomInvalid'));
+ }
+ } else {
+ setRom(null);
+ localforage.removeItem('alttpBaseRom');
+ toastr.success(i18n.t('alttp.baseRomRemoved'));
+ }
+ }, [setRom]);
+
+ React.useEffect(async () => {
+ const stored = await localforage.getItem('alttpBaseRom');
+ if (stored) {
+ const crc = CRC32.buf(new Uint8Array(stored));
+ if (crc == 0x3322EFFC) {
+ setRom(stored);
+ }
+ }
+ }, []);
+
+ return <AlttpBaseRomContext.Provider value={{ rom, setRom: setRomCallback }}>
+ {children}
+ </AlttpBaseRomContext.Provider>;
+};
+
+AlttpBaseRomProvider.propTypes = {
+ children: PropTypes.node,
+};
+
+export const useAlttpBaseRom = () => React.useContext(AlttpBaseRomContext);
+
+export default AlttpBaseRomProvider;
/* eslint-disable max-len */
export default {
translation: {
+ alttp: {
+ baseRomInvalid: 'CRC32 Check fehlgeschlagen (brauche 33:22:EF:FC). Falsche ROM Datei?',
+ baseRomRemoved: 'Base ROM entfernt.',
+ baseRomSet: 'Base ROM gespeichert.',
+ setBaseRom: 'Base ROM auswählen',
+ },
+ alttpSeeds: {
+ date: '{{ date, L LT }}',
+ fetchingPatch: 'Lade Patch',
+ filename: 'alttpr - {{preset}} - {{hash}}',
+ heading: 'A Link to the Past Randomizer Seed',
+ generated: 'Generiert',
+ generator: 'Generator',
+ generators: {
+ doors: 'Dieser Seed wurde mit dem Door Randomizer von Aerinon generiert',
+ },
+ mystery: 'Mystery ROM, Einstellungen versteckt',
+ noMystery: 'Kein Mystery',
+ noRace: 'Kein Race',
+ patch: 'ROM patchen',
+ patchError: 'Fehler beim Patchen: {{msg}}',
+ preset: 'Preset',
+ presets: {
+ custom: 'Eigenes',
+ },
+ race: 'Race ROM, Seed versteckt',
+ seed: 'Seed',
+ settingName: {
+ shuffleenemies: 'Enemy Shuffle',
+ },
+ settings: 'Settings',
+ settingValue: {
+ shuffleenemies: {
+ shuffled: 'Shuffled',
+ },
+ },
+ status: 'Status',
+ statuses: {
+ error: 'Fehler',
+ generated: 'generiert',
+ pending: 'ausstehend',
+ },
+ },
aos: {
baseRomInvalid: 'CRC32 Check fehlgeschlagen (brauche 35:53:61:83). Falsche ROM Datei?',
baseRomRemoved: 'Base ROM entfernt.',
/* eslint-disable max-len */
export default {
translation: {
+ alttp: {
+ baseRomInvalid: 'CRC32 mismatch (need 33:22:EF:FC). Wrong ROM file?',
+ baseRomRemoved: 'Base ROM removed.',
+ baseRomSet: 'Base ROM set.',
+ setBaseRom: 'Set base ROM',
+ },
+ alttpSeeds: {
+ date: '{{ date, L LT }}',
+ fetchingPatch: 'Fetching patch',
+ filename: 'alttpr - {{preset}} - {{hash}}',
+ heading: 'A Link to the Past Randomizer Seed',
+ generated: 'Generated',
+ generator: 'Generator',
+ generators: {
+ doors: 'This seed has been generated with Aerinon\'s door randomizer.',
+ },
+ mystery: 'Mystery ROM, settings hidden',
+ noMystery: 'No mystery',
+ noRace: 'No race',
+ patch: 'Patch ROM',
+ patchError: 'Error applying patch: {{msg}}',
+ preset: 'Preset',
+ presets: {
+ custom: 'Custom',
+ },
+ race: 'Race ROM, seed hidden',
+ seed: 'Seed',
+ settingName: {
+ shuffleenemies: 'Enemy shuffle',
+ },
+ settings: 'Settings',
+ settingValue: {
+ shuffleenemies: {
+ shuffled: 'Shuffled',
+ },
+ },
+ status: 'Status',
+ statuses: {
+ error: 'error',
+ generated: 'generated',
+ pending: 'pending',
+ },
+ },
aos: {
baseRomInvalid: 'CRC32 mismatch (need 35:53:61:83). Wrong ROM file?',
baseRomRemoved: 'Base ROM removed.',
return $request->user();
});
+Route::get('alttp-seed/{hash}', 'App\Http\Controllers\AlttpSeedController@byHash');
+Route::post('alttp-seed/{hash}/retry', 'App\Http\Controllers\AlttpSeedController@retry');
+
Route::get('aos-seed/{hash}', 'App\Http\Controllers\AosSeedController@byHash');
Route::post('aos-seed/{hash}/retry', 'App\Http\Controllers\AosSeedController@retry');