]> git.localhorst.tv Git - alttp.git/commitdiff
basic alttp patcher
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 18 May 2022 12:42:22 +0000 (14:42 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 18 May 2022 12:42:22 +0000 (14:42 +0200)
app/Http/Controllers/AlttpSeedController.php [new file with mode: 0644]
public/alttp-seeds [new symlink]
resources/js/components/App.js
resources/js/components/alttp-seeds/BaseRomButton.js [new file with mode: 0644]
resources/js/components/alttp-seeds/Seed.js [new file with mode: 0644]
resources/js/components/pages/AlttpSeed.js [new file with mode: 0644]
resources/js/helpers/AlttpBaseRomContext.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

diff --git a/app/Http/Controllers/AlttpSeedController.php b/app/Http/Controllers/AlttpSeedController.php
new file mode 100644 (file)
index 0000000..571a44a
--- /dev/null
@@ -0,0 +1,37 @@
+<?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();
+       }
+
+}
diff --git a/public/alttp-seeds b/public/alttp-seeds
new file mode 120000 (symlink)
index 0000000..720b947
--- /dev/null
@@ -0,0 +1 @@
+/home/holy/alttp/storage/app/alttp-seeds
\ No newline at end of file
index 817147fb6f69e9c5ff845050c1b813d7a01448fa..e5d3578baa55b75f090401d520f128b920c1c1ac 100644 (file)
@@ -3,8 +3,10 @@ import React, { useEffect, useState } from 'react';
 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 = () => {
@@ -48,14 +50,17 @@ 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>;
 };
 
diff --git a/resources/js/components/alttp-seeds/BaseRomButton.js b/resources/js/components/alttp-seeds/BaseRomButton.js
new file mode 100644 (file)
index 0000000..e8954d5
--- /dev/null
@@ -0,0 +1,39 @@
+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);
diff --git a/resources/js/components/alttp-seeds/Seed.js b/resources/js/components/alttp-seeds/Seed.js
new file mode 100644 (file)
index 0000000..e804ae3
--- /dev/null
@@ -0,0 +1,139 @@
+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);
diff --git a/resources/js/components/pages/AlttpSeed.js b/resources/js/components/pages/AlttpSeed.js
new file mode 100644 (file)
index 0000000..96589d4
--- /dev/null
@@ -0,0 +1,103 @@
+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;
diff --git a/resources/js/helpers/AlttpBaseRomContext.js b/resources/js/helpers/AlttpBaseRomContext.js
new file mode 100644 (file)
index 0000000..70ca6ad
--- /dev/null
@@ -0,0 +1,52 @@
+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;
index a51c84679e0af4d8bee1fab8f1e52ba99bc0be08..2ae950ce99ad735ceb2744b2f1bbdfed730a4ebd 100644 (file)
@@ -1,6 +1,49 @@
 /* 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.',
index 64d38bb609feeeab23838b174334865260e1e342..a1b9966e51389f3e3f69da43dda3847775ab027f 100644 (file)
@@ -1,6 +1,49 @@
 /* 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.',
index f43505aa4374c4aa16615493a00f84d8c42532d8..c5f18ee34ba4dd912e9f95dbb30f013eed5291dc 100644 (file)
@@ -18,6 +18,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
        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');