]> git.localhorst.tv Git - alttp.git/commitdiff
basic auto tracking
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 23 Mar 2024 13:11:45 +0000 (14:11 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 23 Mar 2024 13:11:45 +0000 (14:11 +0100)
47 files changed:
icons.sh [new file with mode: 0755]
public/item/aga.png [new file with mode: 0644]
public/item/armos.png [new file with mode: 0644]
public/item/arrghus.png [new file with mode: 0644]
public/item/blind.png [new file with mode: 0644]
public/item/bottle-bee.png
public/item/chest.png [new file with mode: 0644]
public/item/heart-0.png [new file with mode: 0644]
public/item/heart-1.png [new file with mode: 0644]
public/item/heart-2.png [new file with mode: 0644]
public/item/heart-3.png [new file with mode: 0644]
public/item/helma.png [new file with mode: 0644]
public/item/kholdstare.png [new file with mode: 0644]
public/item/lanmolas.png [new file with mode: 0644]
public/item/moldorm.png [new file with mode: 0644]
public/item/mothula.png [new file with mode: 0644]
public/item/open-chest.png [new file with mode: 0644]
public/item/red-crystal.png [new file with mode: 0644]
public/item/small-key.png
public/item/sword-1.png [new file with mode: 0644]
public/item/sword-2.png [new file with mode: 0644]
public/item/sword-3.png [new file with mode: 0644]
public/item/sword-4.png [new file with mode: 0644]
public/item/trinexx.png [new file with mode: 0644]
public/item/vitreous.png [new file with mode: 0644]
public/items-v1.png [new file with mode: 0644]
public/items.png [new file with mode: 0644]
resources/js/app/Routes.js
resources/js/components/common/ZeldaIcon.js
resources/js/components/tracker/AutoTracking.js [new file with mode: 0644]
resources/js/components/tracker/CountDisplay.js [new file with mode: 0644]
resources/js/components/tracker/Dungeons.js [new file with mode: 0644]
resources/js/components/tracker/Equipment.js [new file with mode: 0644]
resources/js/components/tracker/Items.js [new file with mode: 0644]
resources/js/components/tracker/ToggleIcon.js [new file with mode: 0644]
resources/js/components/tracker/Toolbar.js [new file with mode: 0644]
resources/js/components/tracker/index.js [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGameAutoTracking.js
resources/js/helpers/alttp-ram.js
resources/js/helpers/tracker.js [new file with mode: 0644]
resources/js/hooks/tracker.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Tracker.js [new file with mode: 0644]
resources/sass/app.scss
resources/sass/common.scss
resources/sass/tracker.scss [new file with mode: 0644]

diff --git a/icons.sh b/icons.sh
new file mode 100755 (executable)
index 0000000..c9fe293
--- /dev/null
+++ b/icons.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+gm montage -geometry '32x32>' -background transparent -gravity center -tile 8x100 public/item/*.png public/items-v1.png
+
+echo 'const ITEM_MAP = ['
+for i in public/item/*.png
+do
+       basename=$(basename "$i")
+       echo -e "\t'${basename/\.png/}',"
+done
+echo '];'
diff --git a/public/item/aga.png b/public/item/aga.png
new file mode 100644 (file)
index 0000000..3eda6c8
Binary files /dev/null and b/public/item/aga.png differ
diff --git a/public/item/armos.png b/public/item/armos.png
new file mode 100644 (file)
index 0000000..2b92c87
Binary files /dev/null and b/public/item/armos.png differ
diff --git a/public/item/arrghus.png b/public/item/arrghus.png
new file mode 100644 (file)
index 0000000..1510975
Binary files /dev/null and b/public/item/arrghus.png differ
diff --git a/public/item/blind.png b/public/item/blind.png
new file mode 100644 (file)
index 0000000..875142d
Binary files /dev/null and b/public/item/blind.png differ
index 20ad511a1390d3548c82769d59b4a9ae2a2a9281..04a929b13fd723122b750dfca07ae9ec565367eb 100644 (file)
Binary files a/public/item/bottle-bee.png and b/public/item/bottle-bee.png differ
diff --git a/public/item/chest.png b/public/item/chest.png
new file mode 100644 (file)
index 0000000..9bba19d
Binary files /dev/null and b/public/item/chest.png differ
diff --git a/public/item/heart-0.png b/public/item/heart-0.png
new file mode 100644 (file)
index 0000000..a9984d7
Binary files /dev/null and b/public/item/heart-0.png differ
diff --git a/public/item/heart-1.png b/public/item/heart-1.png
new file mode 100644 (file)
index 0000000..f0044b7
Binary files /dev/null and b/public/item/heart-1.png differ
diff --git a/public/item/heart-2.png b/public/item/heart-2.png
new file mode 100644 (file)
index 0000000..328cece
Binary files /dev/null and b/public/item/heart-2.png differ
diff --git a/public/item/heart-3.png b/public/item/heart-3.png
new file mode 100644 (file)
index 0000000..1fa2113
Binary files /dev/null and b/public/item/heart-3.png differ
diff --git a/public/item/helma.png b/public/item/helma.png
new file mode 100644 (file)
index 0000000..fd2d37a
Binary files /dev/null and b/public/item/helma.png differ
diff --git a/public/item/kholdstare.png b/public/item/kholdstare.png
new file mode 100644 (file)
index 0000000..54f0f2e
Binary files /dev/null and b/public/item/kholdstare.png differ
diff --git a/public/item/lanmolas.png b/public/item/lanmolas.png
new file mode 100644 (file)
index 0000000..86b80a3
Binary files /dev/null and b/public/item/lanmolas.png differ
diff --git a/public/item/moldorm.png b/public/item/moldorm.png
new file mode 100644 (file)
index 0000000..070d451
Binary files /dev/null and b/public/item/moldorm.png differ
diff --git a/public/item/mothula.png b/public/item/mothula.png
new file mode 100644 (file)
index 0000000..0a7ab90
Binary files /dev/null and b/public/item/mothula.png differ
diff --git a/public/item/open-chest.png b/public/item/open-chest.png
new file mode 100644 (file)
index 0000000..16d2b1f
Binary files /dev/null and b/public/item/open-chest.png differ
diff --git a/public/item/red-crystal.png b/public/item/red-crystal.png
new file mode 100644 (file)
index 0000000..25a3a2a
Binary files /dev/null and b/public/item/red-crystal.png differ
index d38cc7ad37866c417b192d7fd237c9f89207ef68..66ac0a057d6198b3eaa14026691f37e12915dc01 100644 (file)
Binary files a/public/item/small-key.png and b/public/item/small-key.png differ
diff --git a/public/item/sword-1.png b/public/item/sword-1.png
new file mode 100644 (file)
index 0000000..8d1220e
Binary files /dev/null and b/public/item/sword-1.png differ
diff --git a/public/item/sword-2.png b/public/item/sword-2.png
new file mode 100644 (file)
index 0000000..b7b485b
Binary files /dev/null and b/public/item/sword-2.png differ
diff --git a/public/item/sword-3.png b/public/item/sword-3.png
new file mode 100644 (file)
index 0000000..b721d6f
Binary files /dev/null and b/public/item/sword-3.png differ
diff --git a/public/item/sword-4.png b/public/item/sword-4.png
new file mode 100644 (file)
index 0000000..9832624
Binary files /dev/null and b/public/item/sword-4.png differ
diff --git a/public/item/trinexx.png b/public/item/trinexx.png
new file mode 100644 (file)
index 0000000..03217db
Binary files /dev/null and b/public/item/trinexx.png differ
diff --git a/public/item/vitreous.png b/public/item/vitreous.png
new file mode 100644 (file)
index 0000000..e550ea4
Binary files /dev/null and b/public/item/vitreous.png differ
diff --git a/public/items-v1.png b/public/items-v1.png
new file mode 100644 (file)
index 0000000..ef5401a
Binary files /dev/null and b/public/items-v1.png differ
diff --git a/public/items.png b/public/items.png
new file mode 100644 (file)
index 0000000..51e21af
Binary files /dev/null and b/public/items.png differ
index b7269e23b60e94d05b7068f5f16c84a2f8a2a771..0e35ddb57180c383bb8ba6a1cb0c0a8c21f6ba4a 100644 (file)
@@ -150,6 +150,13 @@ const router = createBrowserRouter(
                                        )}
                                />
                        </Route>
+                       <Route
+                               path="tracker"
+                               lazy={() => import(
+                                       /* webpackChunkName: "tracker" */
+                                       '../pages/Tracker'
+                               )}
+                       />
                </Route>
        )
 );
index ef07b9207f3ecbc5bc0d9b28f76889b5be0a8495..13efe31d87498d24c81b94ff3e46c4b1fab3a341 100644 (file)
@@ -4,62 +4,101 @@ import { useTranslation } from 'react-i18next';
 
 import Icon from './Icon';
 
+const ITEM_MAP = [
+    'aga',
+    'armos',
+    'arrghus',
+    'big-key',
+    'blind',
+    'blue-boomerang',
+    'blue-mail',
+    'blue-pendant',
+    'blue-potion',
+    'bombos',
+    'bomb',
+    'book',
+    'boots',
+    'bottle-bee',
+    'bottle',
+    'bowless-silvers',
+    'bow',
+    'bugnet',
+    'byrna',
+    'cape',
+    'chest',
+    'compass',
+    'crystal',
+    'duck',
+    'ether',
+    'fairy',
+    'fighter-shield',
+    'fighter-sword',
+    'fire-rod',
+    'fire-shield',
+    'flippers',
+    'flute',
+    'glove',
+    'gold-sword',
+    'green-mail',
+    'green-pendant',
+    'green-potion',
+    'half-magic',
+    'hammer',
+    'heart-0',
+    'heart-1',
+    'heart-2',
+    'heart-3',
+    'heart-container',
+    'heart-piece',
+    'helma',
+    'hookshot',
+    'ice-rod',
+    'kholdstare',
+    'lamp',
+    'lanmolas',
+    'map',
+    'master-sword',
+    'mirror',
+    'mirror-shield',
+    'mitts',
+    'moldorm',
+    'moonpearl',
+    'mothula',
+    'mushroom',
+    'open-chest',
+    'powder',
+    'quake',
+    'quarter-magic',
+    'red-bomb',
+    'red-boomerang',
+    'red-crystal',
+    'red-mail',
+    'red-pendant',
+    'red-potion',
+    'shovel',
+    'silvers',
+    'small-key',
+    'somaria',
+    'sword-1',
+    'sword-2',
+    'sword-3',
+    'sword-4',
+    'tempered-sword',
+    'trinexx',
+    'vitreous',
+];
+
+const isOnItemMap = name => ITEM_MAP.includes(name);
+
+const getItemMapStyle = name => {
+       const index = ITEM_MAP.indexOf(name);
+       const x = index % 8;
+       const y = Math.floor(index / 8);
+       return { backgroundPosition: `-${x * 100}% -${y * 100}%` };
+};
+
 const getIconURL = name => {
        switch (name) {
-               case 'big-key':
-               case 'blue-boomerang':
-               case 'blue-mail':
-               case 'blue-pendant':
-               case 'blue-potion':
-               case 'bombos':
-               case 'bomb':
-               case 'book':
-               case 'boots':
-               case 'bottle-bee':
-               case 'bottle':
-               case 'bow':
-               case 'bugnet':
-               case 'byrna':
-               case 'cape':
-               case 'compass':
-               case 'crystal':
-               case 'duck':
-               case 'ether':
-               case 'fairy':
-               case 'fighter-shield':
-               case 'fighter-sword':
-               case 'fire-rod':
-               case 'fire-shield':
-               case 'flippers':
-               case 'flute':
-               case 'glove':
-               case 'green-mail':
-               case 'green-pendant':
-               case 'green-potion':
-               case 'hammer':
-               case 'heart-container':
-               case 'heart-piece':
-               case 'hookshot':
-               case 'ice-rod':
-               case 'lamp':
-               case 'map':
-               case 'mirror':
-               case 'mirror-shield':
-               case 'mitts':
-               case 'moonpearl':
-               case 'mushroom':
-               case 'powder':
-               case 'quake':
-               case 'red-bomb':
-               case 'red-boomerang':
-               case 'red-mail':
-               case 'red-pendant':
-               case 'red-potion':
-               case 'shovel':
-               case 'silvers':
-               case 'small-key':
-               case 'somaria':
-                       return `/item/${name}.png`;
                case 'dungeon-ct':
                case 'dungeon-dp':
                case 'dungeon-ep':
@@ -93,6 +132,13 @@ const ZeldaIcon = ({ name, title }) => {
        const realTitle = title !== '' ? title || alt : null;
 
        return <span className="zelda-icon">
+               {isOnItemMap(strippedName) ?
+                       <span
+                               className="item-map-icon"
+                               style={getItemMapStyle(strippedName)}
+                               title={realTitle}
+                       />
+               : null}
                {src ?
                        <img
                                alt={alt}
@@ -109,7 +155,7 @@ const ZeldaIcon = ({ name, title }) => {
 };
 
 ZeldaIcon.propTypes = {
-       name: PropTypes.string,
+       name: PropTypes.string.isRequired,
        title: PropTypes.string,
 };
 
diff --git a/resources/js/components/tracker/AutoTracking.js b/resources/js/components/tracker/AutoTracking.js
new file mode 100644 (file)
index 0000000..8528856
--- /dev/null
@@ -0,0 +1,153 @@
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+       IN_GAME_MODES,
+       RAM_ADDR,
+       SRAM_ADDR,
+       WRAM_ADDR,
+       buildPrizeMap,
+} from '../../helpers/alttp-ram';
+import { computeState, mergeStates } from '../../helpers/tracker';
+import { useSNES } from '../../hooks/snes';
+import { useTracker } from '../../hooks/tracker';
+
+const AutoTracking = () => {
+       const [enabled, setEnabled] = React.useState(false);
+       const [prizeMap, setPrizeMap] = React.useState(buildPrizeMap());
+
+       const {
+               disable: disableSNES,
+               enable: enableSNES,
+               openSettings,
+               sock,
+               status,
+       } = useSNES();
+       const { config, setState } = useTracker();
+       const { t } = useTranslation();
+
+       const enable = React.useCallback(() => {
+               enableSNES();
+               setEnabled(true);
+       }, []);
+
+       const disable = React.useCallback(() => {
+               disableSNES();
+               setEnabled(false);
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('tracker.settings');
+               if (savedSettings) {
+                       const settings = JSON.parse(savedSettings);
+                       if (settings.autoTrack) {
+                               enable();
+                       }
+               }
+       }, []);
+
+       const saveSettings = React.useCallback((newSettings) => {
+               const savedSettings = localStorage.getItem('tracker.settings');
+               const settings = savedSettings
+                       ? { ...JSON.parse(savedSettings), ...newSettings }
+                       : newSettings;
+               localStorage.setItem('tracker.settings', JSON.stringify(settings));
+       }, []);
+
+       const toggle = React.useCallback(() => {
+               if (enabled) {
+                       disable();
+                       saveSettings({ autoTrack: false });
+               } else {
+                       enable();
+                       saveSettings({ autoTrack: true });
+               }
+       }, [enabled]);
+
+       // poll game and push state
+       React.useEffect(() => {
+               if (!enabled || status.error || !status.connected || !status.device) return;
+               const updateState = () => {
+                       const saveStart = WRAM_ADDR.SAVE_DATA;
+                       const saveSize = SRAM_ADDR.INV_END;
+                       sock.current.readWRAM(saveStart, saveSize, (data) => {
+                               const computed = computeState(data, prizeMap);
+                               setState(s => mergeStates(config, s, computed));
+                       });
+               };
+               const fetchPrizes = () => {
+                       sock.current.readBytes(RAM_ADDR.PRIZE_MAP, 13, (prizes) => {
+                               sock.current.readBytes(RAM_ADDR.CRYSTAL_MAP, 13, (crystals) => {
+                                       setPrizeMap(m => {
+                                               const newMap = buildPrizeMap(prizes, crystals);
+                                               return JSON.stringify(m) === JSON.stringify(newMap) ? m : newMap;
+                                       });
+                               });
+                       });
+               };
+               const checkInGame = () => {
+                       sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
+                               if (IN_GAME_MODES.includes(data[0])) {
+                                       fetchPrizes();
+                                       updateState();
+                               }
+                       });
+               };
+               const timer = setInterval(checkInGame, 1000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [enabled && !status.error && status.connected && status.device, config, prizeMap, sock]);
+
+       const statusMsg = React.useMemo(() => {
+               if (!enabled) {
+                       return 'disabled';
+               }
+               if (status.error) {
+                       return 'error';
+               }
+               if (!status.connected) {
+                       return 'disconnected';
+               }
+               if (!status.device) {
+                       return 'no-device';
+               }
+               return 'tracking';
+       }, [enabled, status]);
+
+       return <div>
+               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
+                       <Icon.WARNING
+                               className="me-2 text-warning"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
+                       <Icon.INFO
+                               className="me-2 text-info"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               <Button
+                       className="me-2"
+                       onClick={openSettings}
+                       size="sm"
+                       title={t('snes.settings')}
+                       variant="outline-secondary"
+               >
+                       <Icon.SETTINGS title="" />
+               </Button>
+               <ToggleSwitch
+                       onChange={toggle}
+                       title={t('autoTracking.heading')}
+                       value={enabled}
+               />
+       </div>;
+};
+
+export default AutoTracking;
diff --git a/resources/js/components/tracker/CountDisplay.js b/resources/js/components/tracker/CountDisplay.js
new file mode 100644 (file)
index 0000000..ed9b91f
--- /dev/null
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const CountDisplay = ({ className, count }) => {
+       const classNames = ['count-display'];
+       if (className) {
+               classNames.push(className);
+       }
+       if (!count) {
+               classNames.push('is-zero');
+       }
+       return <span className={classNames.join(' ')}>
+               {count}
+       </span>;
+};
+
+CountDisplay.propTypes = {
+       className: PropTypes.string,
+       count: PropTypes.number,
+};
+
+export default CountDisplay;
diff --git a/resources/js/components/tracker/Dungeons.js b/resources/js/components/tracker/Dungeons.js
new file mode 100644 (file)
index 0000000..07b6638
--- /dev/null
@@ -0,0 +1,62 @@
+import React from 'react';
+
+import CountDisplay from './CountDisplay';
+import ToggleIcon from './ToggleIcon';
+import { useTracker } from '../../hooks/tracker';
+
+const Dungeons = () => {
+       const { dungeons, state } = useTracker();
+
+       return <div className="dungeons">
+               {dungeons.map(dungeon =>
+                       <div className={`dungeon dungeon-${dungeon.id}`} key={dungeon.id}>
+                               <span className="dungeon-tag">{dungeon.id.toUpperCase()}</span>
+                               <ToggleIcon
+                                       controller={ToggleIcon.dungeonController(dungeon)}
+                                       icons={['map']}
+                               />
+                               <ToggleIcon
+                                       controller={ToggleIcon.dungeonController(dungeon)}
+                                       icons={['compass']}
+                               />
+                               <span className="dungeon-smalls">
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonCountController(dungeon, dungeon.sk)}
+                                               icons={['small-key']}
+                                       />
+                                       <CountDisplay count={state[`${dungeon.id}-small-key`] || 0} />
+                               </span>
+                               <ToggleIcon
+                                       controller={ToggleIcon.dungeonController(dungeon)}
+                                       icons={['big-key']}
+                               />
+                               <span className="dungeon-checks">
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonCheckController(dungeon, dungeon.items)}
+                                               icons={['open-chest', 'chest']}
+                                       />
+                                       <CountDisplay count={dungeon.items - (state[`${dungeon.id}-checks`] || 0)} />
+                               </span>
+                               {dungeon.boss ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonBossController(dungeon)}
+                                               icons={dungeon.bosses}
+                                       />
+                               : null}
+                               {dungeon.prize ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonPrizeController(dungeon)}
+                                               icons={[
+                                                       'crystal',
+                                                       'red-crystal',
+                                                       'green-pendant',
+                                                       'red-pendant',
+                                               ]}
+                                       />
+                               : null}
+                       </div>
+               )}
+       </div>;
+};
+
+export default Dungeons;
diff --git a/resources/js/components/tracker/Equipment.js b/resources/js/components/tracker/Equipment.js
new file mode 100644 (file)
index 0000000..bfb9b20
--- /dev/null
@@ -0,0 +1,59 @@
+import React from 'react';
+
+import CountDisplay from './CountDisplay';
+import ToggleIcon from './ToggleIcon';
+import { useTracker } from '../../hooks/tracker';
+
+const Equipment = () => {
+       const { state } = useTracker();
+
+       return <div className="equipment">
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['boots']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('lift', 0, 2)}
+                               icons={['glove', 'mitts']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['flippers']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['moonpearl']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.simpleController}
+                               icons={['half-magic', 'quarter-magic']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('sword', 0, 4)}
+                               icons={['sword-1', 'sword-2', 'sword-3', 'sword-4']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('shield', 0, 3)}
+                               icons={['fighter-shield', 'fire-shield', 'mirror-shield']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('mail', 1, 3)}
+                               icons={['green-mail', 'blue-mail', 'red-mail']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.modulusController('heart-piece')}
+                               icons={['heart-0', 'heart-1', 'heart-2', 'heart-3']}
+                       />
+               </div>
+       </div>;
+};
+
+export default Equipment;
diff --git a/resources/js/components/tracker/Items.js b/resources/js/components/tracker/Items.js
new file mode 100644 (file)
index 0000000..712ee6c
--- /dev/null
@@ -0,0 +1,102 @@
+import React from 'react';
+
+import CountDisplay from './CountDisplay';
+import ToggleIcon from './ToggleIcon';
+import { useTracker } from '../../hooks/tracker';
+
+const Items = () => {
+       const { state } = useTracker();
+
+       return <div className="items">
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['bow', 'silvers']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               className="left"
+                               controller={ToggleIcon.simpleController}
+                               icons={['blue-boomerang']}
+                       />
+                       <ToggleIcon
+                               className="right"
+                               controller={ToggleIcon.simpleController}
+                               icons={['red-boomerang']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['hookshot']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['bomb']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               className="bottom-left"
+                               controller={ToggleIcon.simpleController}
+                               icons={['mushroom']}
+                       />
+                       <ToggleIcon
+                               className="top-right"
+                               controller={ToggleIcon.simpleController}
+                               icons={['powder']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['fire-rod']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['ice-rod']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['bombos']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['ether']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['quake']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['lamp']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['hammer']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               className="bottom-left"
+                               controller={ToggleIcon.simpleController}
+                               icons={['shovel']}
+                       />
+                       <ToggleIcon
+                               className="top-right"
+                               controller={ToggleIcon.simpleController}
+                               icons={['flute', 'duck']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['bugnet']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['book']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.countController(4)} icons={['bottle']} />
+                       <CountDisplay className="bottom-right" count={state.bottle || 0} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['somaria']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['byrna']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['cape']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['mirror']} />
+               </div>
+       </div>;
+};
+
+export default Items;
diff --git a/resources/js/components/tracker/ToggleIcon.js b/resources/js/components/tracker/ToggleIcon.js
new file mode 100644 (file)
index 0000000..49375ef
--- /dev/null
@@ -0,0 +1,218 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ZeldaIcon from '../common/ZeldaIcon';
+import {
+       decrement,
+       getDungeonBoss,
+       getDungeonPrize,
+       hasDungeonBoss,
+       hasDungeonPrize,
+       highestActive,
+       increment,
+       toggleBoolean,
+} from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const ToggleIcon = ({ controller, className, icons }) => {
+       const { state, setState } = useTracker();
+       const activeController = controller || ToggleIcon.nullController;
+       const active = activeController.getActive(state, icons);
+       const defaultIcon = activeController.getDefault(state, icons);
+       const classNames = ['toggle-icon'];
+       if (active) {
+               classNames.push('active');
+       } else {
+               classNames.push('inactive');
+       }
+       if (className) {
+               classNames.push(className);
+       }
+       return <span
+               className={classNames.join(' ')}
+               onClick={(e) => {
+                       activeController.handlePrimary(state, setState, icons);
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+               onContextMenu={(e) => {
+                       activeController.handleSecondary(state, setState, icons);
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+       >
+               <ZeldaIcon name={active || defaultIcon || icons[0]} />
+       </span>;
+};
+
+const doNothing = () => { };
+
+const firstIcon = (state, icons) => icons[0];
+
+const nextIcon = (state, setState, icons) => {
+       const highest = highestActive(state, icons);
+       const highestIndex = highest ? icons.indexOf(highest) : -1;
+       if (highestIndex + 1 < icons.length) {
+               setState(toggleBoolean(icons[highestIndex + 1]));
+       } else {
+               const changes = {};
+               icons.forEach(icon => {
+                       changes[icon] = false;
+               });
+               setState(s => ({ ...s, ...changes }));
+       }
+};
+
+const previousIcon = (state, setState, icons) => {
+       const highest = highestActive(state, icons);
+       const highestIndex = highest ? icons.indexOf(highest) : -1;
+       if (highestIndex >= 0) {
+               setState(toggleBoolean(icons[highestIndex]));
+       } else {
+               const changes = {};
+               icons.forEach(icon => {
+                       changes[icon] = true;
+               });
+               setState(s => ({ ...s, ...changes }));
+       }
+};
+
+const nextString = property => (state, setState, icons) => {
+       const current = state[property] || icons[0];
+       const currentIndex = icons.indexOf(current);
+       const nextIndex = (currentIndex + 1) % icons.length;
+       const next = icons[nextIndex];
+       setState(s => ({ ...s, [property]: next }));
+};
+
+const previousString = property => (state, setState, icons) => {
+       const current = state[property] || icons[0];
+       const currentIndex = icons.indexOf(current);
+       const previousIndex = (currentIndex + icons.length - 1) % icons.length;
+       const previous = icons[previousIndex];
+       setState(s => ({ ...s, [property]: previous }));
+};
+
+ToggleIcon.countController = max => ({
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(icons[0], max));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(icons[0], max));
+       },
+});
+
+ToggleIcon.dungeonBossController = (dungeon) => ({
+       getActive: (state) => hasDungeonBoss(state, dungeon) ? getDungeonBoss(state, dungeon) : null,
+       getDefault: (state) => getDungeonBoss(state, dungeon),
+       handlePrimary: dungeon.bosses.length > 1
+               ? nextString(`${dungeon.id}-boss`)
+               : (state, setState) => {
+                       setState(toggleBoolean(`${dungeon.id}-boss-defeated`));
+               },
+       handleSecondary: dungeon.bosses.length > 1 ?
+               previousString(`${dungeon.id}-boss`)
+               : (state, setState) => {
+                       setState(toggleBoolean(`${dungeon.id}-boss-defeated`));
+               },
+});
+
+ToggleIcon.dungeonCheckController = (dungeon, max) => ({
+       getActive: (state, icons) => state[`${dungeon.id}-checks`] < max ? icons[1] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState) => {
+               setState(increment(`${dungeon.id}-checks`, max));
+       },
+       handleSecondary: (state, setState) => {
+               setState(decrement(`${dungeon.id}-checks`, max));
+       },
+});
+
+ToggleIcon.dungeonController = dungeon => ({
+       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
+       },
+});
+
+ToggleIcon.dungeonCountController = (dungeon, max) => ({
+       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(`${dungeon.id}-${icons[0]}`, max));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(`${dungeon.id}-${icons[0]}`, max));
+       },
+});
+
+ToggleIcon.dungeonPrizeController = (dungeon) => ({
+       getActive: (state) => hasDungeonPrize(state, dungeon) ? getDungeonPrize(state, dungeon) : null,
+       getDefault: (state) => getDungeonPrize(state, dungeon),
+       handlePrimary: nextString(`${dungeon.id}-prize`),
+       handleSecondary: previousString(`${dungeon.id}-prize`),
+});
+
+ToggleIcon.medallionController = {
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: nextIcon,
+       handleSecondary: doNothing,
+};
+
+ToggleIcon.modulusController = ctrl => ({
+       getActive: (state, icons) => icons[(state[ctrl] || 0) % icons.length],
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(icons[0], icons.length));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(icons[0], icons.length));
+       },
+});
+
+ToggleIcon.nullController = {
+       getActive: () => null,
+       getDefault: firstIcon,
+       handlePrimary: doNothing,
+       handleSecondary: doNothing,
+};
+
+ToggleIcon.simpleController = {
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: nextIcon,
+       handleSecondary: previousIcon,
+};
+
+ToggleIcon.progressiveController = (master, min, max) => ({
+       getActive: (state, icons) => {
+               const count = Math.max(min, Math.min(max, state[master] || 0));
+               return count ? icons[count - 1] : null;
+       },
+       getDefault: firstIcon,
+       handlePrimary: (state, setState) => {
+               setState(increment(master, max, min));
+       },
+       handleSecondary: (state, setState) => {
+               setState(decrement(master, max, min));
+       },
+});
+
+ToggleIcon.propTypes = {
+       active: PropTypes.string,
+       className: PropTypes.string,
+       controller: PropTypes.shape({
+               handlePrimary: PropTypes.func,
+               handleSecondary: PropTypes.func,
+       }),
+       icons: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default ToggleIcon;
diff --git a/resources/js/components/tracker/Toolbar.js b/resources/js/components/tracker/Toolbar.js
new file mode 100644 (file)
index 0000000..169d7bc
--- /dev/null
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Container, Navbar } from 'react-bootstrap';
+
+import AutoTracking from './AutoTracking';
+import ToggleIcon from './ToggleIcon';
+import { useTracker } from '../../hooks/tracker';
+
+const mapWild = {
+       map: 'wildMap',
+       compass: 'wildCompass',
+       'small-key': 'wildSmall',
+       'big-key': 'wildBig',
+};
+
+const Toolbar = () => {
+       const { config, setConfig } = useTracker();
+
+       const controller = React.useMemo(() => ({
+               getActive: (state, icons) => config[mapWild[icons[0]]] ? icons[0] : null,
+               getDefault: (state, icons) => icons[0],
+               handlePrimary: (state, setState, icons) => {
+                       const prop = mapWild[icons[0]];
+                       setConfig(c => ({ ...c, [prop]: !c[prop] }));
+               },
+               handleSecondary: () => null,
+       }), [config, setConfig]);
+
+       return <Navbar bg="dark" className="tracker-toolbar" variant="dark">
+               <Container fluid>
+                       <div className="button-bar">
+                               <ToggleIcon controller={controller} icons={['map']} />
+                               <ToggleIcon controller={controller} icons={['compass']} />
+                               <ToggleIcon controller={controller} icons={['small-key']} />
+                               <ToggleIcon controller={controller} icons={['big-key']} />
+                       </div>
+                       <AutoTracking />
+               </Container>
+       </Navbar>;
+};
+
+export default Toolbar;
diff --git a/resources/js/components/tracker/index.js b/resources/js/components/tracker/index.js
new file mode 100644 (file)
index 0000000..5a505b5
--- /dev/null
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import Dungeons from './Dungeons';
+import Equipment from './Equipment';
+import Items from './Items';
+import Toolbar from './Toolbar';
+
+const Tracker = () => {
+       return <div className="tracker">
+               <Toolbar />
+               <Items />
+               <Equipment />
+               <Dungeons />
+       </div>;
+};
+
+export default Tracker;
index 093e1214aea24cf39b1050550f0a1e5ecd96851a..451c5975ced4e13a87f0f5c4fb6056d86a53041e 100644 (file)
@@ -9,32 +9,14 @@ import {
        compareGTBasementState,
        countGTBasementState,
        getGTBasementState,
+       IN_GAME_MODES,
+       INV_ADDR,
+       RAM_ADDR,
+       SRAM_ADDR,
+       WRAM_ADDR,
 } from '../../helpers/alttp-ram';
 import { useSNES } from '../../hooks/snes';
 
-const IN_GAME_MODES = [
-       0x05, // loading game
-       0x06, // entering dungeon
-       0x07, // dungeon
-       0x08, // entering overworld
-       0x09, // overworld
-       0x0A, // entering special overworld
-       0x0B, // special overworld
-       0x0E, // text/menu/map
-       0x0F, // closing spot
-       0x10, // opening spot
-       0x11, // falling
-       0x12, // dying
-       0x13, // fanfare
-       0x15, // mirror
-       0x16, // refill
-       0x17, // S&Q
-       0x18, // aga 2 cutscene
-       0x19, // triforce room
-       0x1A, // credits
-       0x1B, // spawn select
-];
-
 const GT_TYPES = [
        0x02, // all dungeons
        0x03, // defeat ganon
@@ -45,21 +27,6 @@ const GT_TYPES = [
        0x0B, // completionist
 ];
 
-const FREE_ITEM_MENU = 0x180045;
-const GT_CRYSTALS = 0x18019A;
-const GANON_TYPE = 0x1801A8;
-const SEED_TYPE = 0x180210;
-const INIT_SRAM = 0x183000;
-
-const GAME_MODE = 0x10;
-const CURRENT_DUNGEON = 0x10E;
-const SAVE_WRAM = 0xF000;
-const ROOM_DATA_START = 0x000;
-const ROOM_DATA_END = 0x140;
-const PYRAMID_SCREEN = 0x2DB;
-const BIG_KEYS_1 = 0x366;
-const OWNED_CRYSTALS = 0x37A;
-
 const GT_ENTRANCE_ID = 55;
 
 const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
@@ -167,7 +134,7 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
        React.useEffect(() => {
                if (enabled && !status.error && status.connected && status.device) {
                        const checkInGame = () => {
-                               sock.current.readWRAM(GAME_MODE, 1, (data) => {
+                               sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
                                        setInGame(IN_GAME_MODES.includes(data[0]));
                                });
                        };
@@ -184,19 +151,19 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
        // refresh static game information
        React.useEffect(() => {
                if (!inGame) return;
-               sock.current.readBytes(SEED_TYPE, 1, (data) => {
+               sock.current.readBytes(RAM_ADDR.SEED_TYPE, 1, (data) => {
                        setSeedType(data[0]);
                });
-               sock.current.readBytes(GT_CRYSTALS, 1, (data) => {
+               sock.current.readBytes(RAM_ADDR.GT_CRYSTALS, 1, (data) => {
                        setGTCrystals(data[0]);
                });
-               sock.current.readBytes(GANON_TYPE, 1, (data) => {
+               sock.current.readBytes(RAM_ADDR.GANON_TYPE, 1, (data) => {
                        setGanonType(data[0]);
                });
-               sock.current.readBytes(FREE_ITEM_MENU, 1, (data) => {
+               sock.current.readBytes(RAM_ADDR.FREE_ITEM_MENU, 1, (data) => {
                        setFreeItemMenu(data[0]);
                });
-               sock.current.readBytes(INIT_SRAM + PYRAMID_SCREEN, 1, (data) => {
+               sock.current.readBytes(RAM_ADDR.INIT_SRAM + SRAM_ADDR.PYRAMID_SCREEN, 1, (data) => {
                        setPyramidOpen(!!(data[0] & 0x20));
                });
        }, [inGame, sock]);
@@ -213,7 +180,8 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
        React.useEffect(() => {
                if (!applicable || !inGame || hasBigKey) return;
                const updateCrystals = () => {
-                       sock.current.readWRAM(SAVE_WRAM + OWNED_CRYSTALS, 1, (data) => {
+                       const crAddress = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.CRYSTALS;
+                       sock.current.readWRAM(crAddress, 1, (data) => {
                                let owned = 0;
                                for (let i = 0; i < 7; ++i) {
                                        if (data[0] & Math.pow(2, i)) {
@@ -235,7 +203,7 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
                if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
                controls.current.onStart();
                const updateDungeon = () => {
-                       sock.current.readWRAM(CURRENT_DUNGEON, 2, (data) => {
+                       sock.current.readWRAM(WRAM_ADDR.CURRENT_DUNGEON, 2, (data) => {
                                setLastEntrance(data[0] + (data[1] * 256));
                        });
                };
@@ -258,8 +226,9 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
        React.useEffect(() => {
                if (!applicable || !hasEntered || hasBigKey) return;
                const updateGTState = () => {
-                       const roomDataSize = ROOM_DATA_END - ROOM_DATA_START;
-                       sock.current.readWRAM(SAVE_WRAM + ROOM_DATA_START, roomDataSize, (data) => {
+                       const roomDataStart = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.ROOM_DATA_START;
+                       const roomDataSize = SRAM_ADDR.ROOM_DATA_END - SRAM_ADDR.ROOM_DATA_START;
+                       sock.current.readWRAM(roomDataStart, roomDataSize, (data) => {
                                const gtState = getGTBasementState(data);
                                const gtCount = countGTBasementState(gtState);
                                setBasement(old => {
@@ -288,7 +257,8 @@ const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
                        const solution = basement.last === 'torch' ? basement.torch : basement.count;
                        controls.current.onSolve(solution);
                } else {
-                       sock.current.readWRAM(SAVE_WRAM + BIG_KEYS_1, 1, (data) => {
+                       const bkAddr = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.BIG_KEY;
+                       sock.current.readWRAM(bkAddr, 1, (data) => {
                                setHasBigKey(!!(data[0] & 0x04));
                        });
                }
index 2ae71cf58f48b857aa2b8a14fa6b1311f4d0fc37..d5bfa1bf2ef8f581c78907a09814b456664286ee 100644 (file)
@@ -1,3 +1,165 @@
+export const RAM_ADDR = {
+       PRIZE_MAP: 0x1209B,
+       FREE_ITEM_MENU: 0x180045,
+       CRYSTAL_MAP: 0x180050,
+       GT_CRYSTALS: 0x18019A,
+       GANON_TYPE: 0x1801A8,
+       SEED_TYPE: 0x180210,
+       INIT_SRAM: 0x183000,
+};
+
+export const SRAM_ADDR = {
+       ROOM_DATA_START: 0x000,
+       ROOM_DATA_END: 0x250,
+       OW_DATA_START: 0x280,
+       PYRAMID_SCREEN: 0x2DB,
+       OW_DATA_END: 0x300,
+       INV_START: 0x340,
+       INV_END: 0x4EF,
+};
+
+export const WRAM_ADDR = {
+       GAME_MODE: 0x10,
+       CURRENT_DUNGEON: 0x10E,
+       SAVE_DATA: 0xF000,
+};
+
+export const INV_ADDR = {
+       BOW: 0x00,
+       BOOM: 0x01,
+       HOOK: 0x02,
+       BOMB: 0x03,
+       POWDER: 0x04,
+       FROD: 0x05,
+       IROD: 0x06,
+       BOMBOS: 0x07,
+       ETHER: 0x08,
+       QUAKE: 0x09,
+       LAMP: 0x0A,
+       HAMMER: 0x0B,
+       FLUTE: 0x0C,
+       BUGNET: 0x0D,
+       BOOK: 0x0E,
+       BOTTLE: 0x0F,
+       SOMARIA: 0x10,
+       BYRNA: 0x11,
+       CAPE: 0x12,
+       MIRROR: 0x13,
+       GLOVE: 0x14,
+       BOOTS: 0x15,
+       FLIPPERS: 0x16,
+       MOONPEARL: 0x17,
+       SWORD: 0x19,
+       SHIELD: 0x1A,
+       ARMOR: 0x1B,
+       BOTTLE_1: 0x1C,
+       BOTTLE_2: 0x1D,
+       BOTTLE_3: 0x1E,
+       BOTTLE_4: 0x1F,
+       WALLET: 0x20,
+       RUPEES: 0x22,
+       COMPASS: 0x24,
+       BIG_KEY: 0x26,
+       MAP: 0x28,
+       HEART_PIECE: 0x2B,
+       HEALTH: 0x2C,
+       MAGIC: 0x2E,
+       KEYS: 0x2F,
+       PENDANTS: 0x34,
+       ARROWS: 0x37,
+       ABILITIES: 0x39,
+       CRYSTALS: 0x3A,
+       MAGIC_USE: 0x3B,
+       SMALL_KEY_START: 0x3C,
+       SMALL_KEY_END: 0x4C,
+       RANDO_BOOM: 0x4C,
+       RANDO_POWDER: 0x4C,
+       RANDO_FLUTE: 0x4C,
+       RANDO_BOW: 0x4E,
+       RANDO_KEY_START: 0x1A0,
+       RANDO_KEY_END: 0x1AF,
+};
+
+export const DUNGEON_IDS = {
+       SEWERS: 0,
+       HC: 1,
+       EP: 2,
+       DP: 3,
+       CT: 4,
+       SP: 5,
+       PD: 6,
+       MM: 7,
+       SW: 8,
+       IP: 9,
+       TH: 10,
+       TT: 11,
+       TR: 12,
+       GT: 13,
+};
+
+export const DUNGEON_MASKS = {
+       SEWERS: 0x0080,
+       HC: 0x0040,
+       EP: 0x0020,
+       DP: 0x0010,
+       CT: 0x0008,
+       SP: 0x0004,
+       PD: 0x0002,
+       MM: 0x0001,
+       SW: 0x8000,
+       IP: 0x4000,
+       TH: 0x2000,
+       TT: 0x1000,
+       TR: 0x0800,
+       GT: 0x0400,
+};
+
+export const ABILITY_MASKS = {
+       SWIM: 0x02,
+       DASH: 0x04,
+       PULL: 0x08,
+       TALK: 0x20,
+       READ: 0x40,
+};
+
+export const IN_GAME_MODES = [
+       0x05, // loading game
+       0x06, // entering dungeon
+       0x07, // dungeon
+       0x08, // entering overworld
+       0x09, // overworld
+       0x0A, // entering special overworld
+       0x0B, // special overworld
+       0x0E, // text/menu/map
+       0x0F, // closing spot
+       0x10, // opening spot
+       0x11, // falling
+       0x12, // dying
+       0x13, // fanfare
+       0x15, // mirror
+       0x16, // refill
+       0x17, // S&Q
+       0x18, // aga 2 cutscene
+       0x19, // triforce room
+       0x1B, // spawn select
+];
+
+export const getShort = (data, offset) => (data[offset] * 256) + data[offset + 1];
+
+export const buildPrizeMap = (prizes, crystals) => {
+       const map = {};
+       Object.entries(DUNGEON_IDS).forEach(([, id]) => {
+               const isCrystal = !!(crystals && crystals[id]);
+               const mask = (prizes && prizes[id]) || 0;
+               map[id] = { isCrystal, mask };
+       });
+       return map;
+};
+
+export const isBossDefeated = (data, room) => {
+       return !!(data && (data[(2 * room) + 1] & 0x08));
+};
+
 export const isChestOpen = (data, room, chest) => {
        if (chest < 4) {
                return !!(data && (data[2 * room] & Math.pow(2, chest + 4)));
diff --git a/resources/js/helpers/tracker.js b/resources/js/helpers/tracker.js
new file mode 100644 (file)
index 0000000..707e3f7
--- /dev/null
@@ -0,0 +1,1753 @@
+import {
+       DUNGEON_IDS,
+       DUNGEON_MASKS,
+       INV_ADDR,
+       SRAM_ADDR,
+       getShort,
+       isBossDefeated,
+       isChestOpen,
+} from './alttp-ram';
+
+export const BOOLEAN_STATES = [
+       'blue-boomerang',
+       'bomb',
+       'bombos',
+       'bow',
+       'bowless-silvers',
+       'book',
+       'boots',
+       'bugnet',
+       'byrna',
+       'cape',
+       'duck',
+       'ether',
+       'fire-rod',
+       'flippers',
+       'flute',
+       'half-magic',
+       'hammer',
+       'hookshot',
+       'ice-rod',
+       'lamp',
+       'mirror',
+       'moonpearl',
+       'mushroom',
+       'powder',
+       'quake',
+       'quarter-magic',
+       'red-boomerang',
+       'shovel',
+       'silvers',
+       'somaria',
+];
+
+export const INTEGER_STATES = [
+       'bottle',
+       'heart-piece',
+       'lift',
+       'mail',
+       'shield',
+       'sword',
+];
+
+export const INITIAL = {
+       mail: 1,
+};
+
+export const BOSSES = [
+       'armos',
+       'lanmolas',
+       'moldorm',
+       'helma',
+       'arrghus',
+       'mothula',
+       'blind',
+       'kholdstare',
+       'vitreous',
+       'trinexx',
+];
+
+export const CONFIG = {
+       wildMap: false,
+       wildCompass: false,
+       wildSmall: false,
+       wildBig: false,
+       bossShuffle: false,
+};
+
+export const DUNGEONS = [
+       {
+               id: 'hc',
+               map: true,
+               compass: false,
+               sk: 1,
+               bk: true,
+               dropBk: true,
+               items: 6,
+               boss: null,
+               bosses: [],
+               bossRoom: 0x80,
+               prize: false,
+               offset: DUNGEON_IDS.HC,
+               mask: DUNGEON_MASKS.HC,
+               checks: [
+                       'dark-cross',
+                       'hc-map',
+                       'hc-boom',
+                       'hc-cell',
+                       'sanc',
+                       'sewers-left',
+                       'sewers-mid',
+                       'sewers-right',
+               ],
+       },
+       {
+               id: 'ct',
+               map: false,
+               compass: false,
+               sk: 2,
+               bk: false,
+               items: 0,
+               boss: 'aga',
+               bosses: ['aga'],
+               bossRoom: 0x20,
+               prize: false,
+               offset: DUNGEON_IDS.CT,
+               mask: DUNGEON_MASKS.CT,
+               checks: [
+                       'ct-1',
+                       'ct-2',
+               ],
+       },
+       {
+               id: 'gt',
+               map: true,
+               compass: true,
+               sk: 4,
+               bk: true,
+               items: 20,
+               boss: 'aga',
+               bosses: ['aga'],
+               bossRoom: 0x0D,
+               prize: false,
+               offset: DUNGEON_IDS.GT,
+               mask: DUNGEON_MASKS.GT,
+               checks: [
+                       'gt-hope-left',
+                       'gt-hope-right',
+                       'gt-tile-room',
+                       'gt-compass-tl',
+                       'gt-compass-tr',
+                       'gt-compass-bl',
+                       'gt-compass-br',
+                       'gt-torch',
+                       'gt-dm-tl',
+                       'gt-dm-tr',
+                       'gt-dm-bl',
+                       'gt-dm-br',
+                       'gt-map-chest',
+                       'gt-firesnake',
+                       'gt-rando-tl',
+                       'gt-rando-tr',
+                       'gt-rando-bl',
+                       'gt-rando-br',
+                       'gt-bobs-chest',
+                       'gt-ice-left',
+                       'gt-ice-mid',
+                       'gt-ice-right',
+                       'gt-big-chest',
+                       'gt-helma-left',
+                       'gt-helma-right',
+                       'gt-pre-moldorm',
+                       'gt-post-moldorm',
+               ],
+       },
+       {
+               id: 'ep',
+               map: true,
+               compass: true,
+               sk: 0,
+               bk: true,
+               items: 3,
+               boss: 'armos',
+               bosses: ['armos'],
+               bossRoom: 0xC8,
+               prize: true,
+               isPendant: true,
+               prizeMask: 0x04,
+               offset: DUNGEON_IDS.EP,
+               mask: DUNGEON_MASKS.EP,
+               checks: [
+                       'ep-cannonball',
+                       'ep-map-chest',
+                       'ep-compass-chest',
+                       'ep-big-chest',
+                       'ep-big-key-chest',
+                       'ep-boss-defeated',
+               ],
+       },
+       {
+               id: 'dp',
+               map: true,
+               compass: true,
+               sk: 1,
+               bk: true,
+               items: 2,
+               boss: 'lanmolas',
+               bosses: ['lanmolas'],
+               bossRoom: 0x33,
+               prize: true,
+               isPendant: true,
+               prizeMask: 0x02,
+               offset: DUNGEON_IDS.DP,
+               mask: DUNGEON_MASKS.DP,
+               checks: [
+                       'dp-torch',
+                       'dp-map-chest',
+                       'dp-big-chest',
+                       'dp-compass-chest',
+                       'dp-big-key-chest',
+                       'dp-boss-defeated',
+               ],
+       },
+       {
+               id: 'th',
+               map: true,
+               compass: true,
+               sk: 1,
+               bk: true,
+               items: 2,
+               boss: 'moldorm',
+               bosses: ['moldorm'],
+               bossRoom: 0x07,
+               prize: true,
+               isPendant: true,
+               prizeMask: 0x01,
+               offset: DUNGEON_IDS.TH,
+               mask: DUNGEON_MASKS.TH,
+               checks: [
+                       'th-basement-cage',
+                       'th-map-chest',
+                       'th-big-key-chest',
+                       'th-compass-chest',
+                       'th-big-chest',
+                       'th-boss-defeated',
+               ],
+       },
+       {
+               id: 'pd',
+               map: true,
+               compass: true,
+               sk: 6,
+               bk: true,
+               items: 5,
+               boss: 'helma',
+               bosses: ['helma'],
+               bossRoom: 0x5A,
+               prize: true,
+               prizeMask: 0x02,
+               offset: DUNGEON_IDS.PD,
+               mask: DUNGEON_MASKS.PD,
+               checks: [
+                       'pd-shooter-room',
+                       'pd-stalfos-basement',
+                       'pd-big-key-chest',
+                       'pd-arena-bridge',
+                       'pd-arena-ledge',
+                       'pd-map-chest',
+                       'pd-compass-chest',
+                       'pd-basement-left',
+                       'pd-basement-right',
+                       'pd-harmless-hellway',
+                       'pd-maze-top',
+                       'pd-maze-bottom',
+                       'pd-big-chest',
+                       'pd-boss-defeated',
+               ],
+       },
+       {
+               id: 'sp',
+               map: true,
+               compass: true,
+               sk: 1,
+               bk: true,
+               items: 6,
+               boss: 'arrghus',
+               bosses: ['arrghus'],
+               bossRoom: 0x06,
+               prize: true,
+               prizeMask: 0x10,
+               offset: DUNGEON_IDS.SP,
+               mask: DUNGEON_MASKS.SP,
+               checks: [
+                       'sp-lobby',
+                       'sp-map-chest',
+                       'sp-big-chest',
+                       'sp-compass-chest',
+                       'sp-west-chest',
+                       'sp-big-key-chest',
+                       'sp-flooded-left',
+                       'sp-flooded-right',
+                       'sp-waterfall',
+                       'sp-boss-defeated',
+               ],
+       },
+       {
+               id: 'sw',
+               map: true,
+               compass: true,
+               sk: 3,
+               bk: true,
+               items: 2,
+               boss: 'mothula',
+               bosses: ['mothula'],
+               bossRoom: 0x29,
+               prize: true,
+               prizeMask: 0x40,
+               offset: DUNGEON_IDS.SW,
+               mask: DUNGEON_MASKS.SW,
+               checks: [
+                       'sw-big-chest',
+                       'sw-map-chest',
+                       'sw-pot-prison',
+                       'sw-compass-chest',
+                       'sw-pinball-room',
+                       'sw-big-key-chest',
+                       'sw-bridge-chest',
+                       'sw-boss-defeated',
+               ],
+       },
+       {
+               id: 'tt',
+               map: true,
+               compass: true,
+               sk: 1,
+               bk: true,
+               items: 4,
+               boss: 'blind',
+               bosses: ['blind'],
+               bossRoom: 0xAC,
+               prize: true,
+               prizeMask: 0x20,
+               offset: DUNGEON_IDS.TT,
+               mask: DUNGEON_MASKS.TT,
+               checks: [
+                       'tt-map-chest',
+                       'tt-ambush-chest',
+                       'tt-compass-chest',
+                       'tt-big-key-chest',
+                       'tt-attic',
+                       'tt-cell',
+                       'tt-big-chest',
+                       'tt-boss-defeated',
+               ],
+       },
+       {
+               id: 'ip',
+               map: true,
+               compass: true,
+               sk: 2,
+               bk: true,
+               items: 3,
+               boss: 'kholdstare',
+               bosses: ['kholdstare'],
+               bossRoom: 0xDE,
+               prize: true,
+               prizeMask: 0x04,
+               offset: DUNGEON_IDS.IP,
+               mask: DUNGEON_MASKS.IP,
+               checks: [
+                       'ip-compass-chest',
+                       'ip-big-key-chest',
+                       'ip-map-chest',
+                       'ip-spike-chest',
+                       'ip-freezor-chest',
+                       'ip-big-chest',
+                       'ip-ice-t',
+                       'ip-boss-defeated',
+               ],
+       },
+       {
+               id: 'mm',
+               map: true,
+               compass: true,
+               sk: 3,
+               bk: true,
+               items: 2,
+               boss: 'vitreous',
+               bosses: ['vitreous'],
+               bossRoom: 0x90,
+               prize: true,
+               prizeMask: 0x01,
+               offset: DUNGEON_IDS.MM,
+               mask: DUNGEON_MASKS.MM,
+               checks: [
+                       'mm-bridge-chest',
+                       'mm-spike-chest',
+                       'mm-lobby-chest',
+                       'mm-compass-chest',
+                       'mm-big-key-chest',
+                       'mm-big-chest',
+                       'mm-map-chest',
+                       'mm-boss-defeated',
+               ],
+       },
+       {
+               id: 'tr',
+               map: true,
+               compass: true,
+               sk: 4,
+               bk: true,
+               items: 5,
+               boss: 'trinexx',
+               bosses: ['trinexx'],
+               bossRoom: 0xA4,
+               prize: true,
+               prizeMask: 0x08,
+               offset: DUNGEON_IDS.TR,
+               mask: DUNGEON_MASKS.TR,
+               checks: [
+                       'tr-roller-left',
+                       'tr-roller-right',
+                       'tr-compass-chest',
+                       'tr-chomps',
+                       'tr-big-key-chest',
+                       'tr-big-chest',
+                       'tr-crysta-roller',
+                       'tr-laser-bridge-top',
+                       'tr-laser-bridge-left',
+                       'tr-laser-bridge-right',
+                       'tr-laser-bridge-bottom',
+                       'tr-boss-defeated',
+               ],
+       },
+];
+
+export const OVERWORLD_LOCATIONS = [
+       {
+               id: 'blacksmith',
+               address: 0x411,
+               mask: 0x04,
+       },
+       {
+               id: 'bombos-tablet',
+               address: 0x411,
+               mask: 0x02,
+       },
+       {
+               id: 'bottle-vendor',
+               address: 0x3C9,
+               mask: 0x02,
+       },
+       {
+               id: 'bumper-cave',
+               address: 0x2CA,
+               mask: 0x40,
+       },
+       {
+               id: 'catfish',
+               address: 0x410,
+               mask: 0x20,
+       },
+       {
+               id: 'desert-ledge',
+               address: 0x2B0,
+               mask: 0x40,
+       },
+       {
+               id: 'digging-game',
+               address: 0x2E8,
+               mask: 0x40,
+       },
+       {
+               id: 'ether-tablet',
+               address: 0x411,
+               mask: 0x01,
+       },
+       {
+               id: 'floating-island',
+               address: 0x285,
+               mask: 0x40,
+       },
+       {
+               id: 'flute-spot',
+               address: 0x2AA,
+               mask: 0x40,
+       },
+       {
+               id: 'hobo',
+               address: 0x3C9,
+               mask: 0x01,
+       },
+       {
+               id: 'lake-hylia-island',
+               address: 0x2B5,
+               mask: 0x40,
+       },
+       {
+               id: 'library',
+               address: 0x410,
+               mask: 0x80,
+       },
+       {
+               id: 'magic-bat',
+               address: 0x411,
+               mask: 0x80,
+       },
+       {
+               id: 'mushroom-spot',
+               address: 0x411,
+               mask: 0x10,
+       },
+       {
+               id: 'old-man',
+               address: 0x410,
+               mask: 0x01,
+       },
+       {
+               id: 'pedestal',
+               address: 0x300,
+               mask: 0x40,
+       },
+       {
+               id: 'potion-shop',
+               address: 0x411,
+               mask: 0x20,
+       },
+       {
+               id: 'purple-chest',
+               address: 0x3C9,
+               mask: 0x10,
+       },
+       {
+               id: 'pyramid',
+               address: 0x2DB,
+               mask: 0x40,
+       },
+       {
+               id: 'race-game',
+               address: 0x2A8,
+               mask: 0x40,
+       },
+       {
+               id: 'saha',
+               address: 0x410,
+               mask: 0x10,
+       },
+       {
+               id: 'sick-kid',
+               address: 0x410,
+               mask: 0x04,
+       },
+       {
+               id: 'spec-rock',
+               address: 0x283,
+               mask: 0x40,
+       },
+       {
+               id: 'stumpy',
+               address: 0x410,
+               mask: 0x08,
+       },
+       {
+               id: 'sunken-treasure',
+               address: 0x2BB,
+               mask: 0x40,
+       },
+       {
+               id: 'uncle',
+               address: 0x3C6,
+               mask: 0x01,
+       },
+       {
+               id: 'zora',
+               address: 0x410,
+               mask: 0x02,
+       },
+       {
+               id: 'zora-ledge',
+               address: 0x301,
+               mask: 0x40,
+       },
+];
+
+export const UNDERWORLD_LOCATIONS = [
+       {
+               id: 'aginah',
+               room: 0x10A,
+               chest: 0,
+       },
+       {
+               id: 'blinds-hut-top',
+               room: 0x11D,
+               chest: 0,
+       },
+       {
+               id: 'blinds-hut-left',
+               room: 0x11D,
+               chest: 1,
+       },
+       {
+               id: 'blinds-hut-right',
+               room: 0x11D,
+               chest: 2,
+       },
+       {
+               id: 'blinds-hut-far-left',
+               room: 0x11D,
+               chest: 3,
+       },
+       {
+               id: 'blinds-hut-far-right',
+               room: 0x11D,
+               chest: 4,
+       },
+       {
+               id: 'bonk-rocks',
+               room: 0x124,
+               chest: 0,
+       },
+       {
+               id: 'brewery',
+               room: 0x106,
+               chest: 0,
+       },
+       {
+               id: 'c-house',
+               room: 0x11C,
+               chest: 0,
+       },
+       {
+               id: 'cave-45',
+               room: 0x11B,
+               chest: 6,
+       },
+       {
+               id: 'checkerboard',
+               room: 0x126,
+               chest: 5,
+       },
+       {
+               id: 'chest-game',
+               room: 0x106,
+               chest: 6,
+       },
+       {
+               id: 'chicken-house',
+               room: 0x108,
+               chest: 0,
+       },
+       {
+               id: 'ct-1',
+               area: 'ct',
+               room: 0xE0,
+               chest: 0,
+       },
+       {
+               id: 'ct-2',
+               area: 'ct',
+               room: 0xD0,
+               chest: 0,
+       },
+       {
+               id: 'dark-cross',
+               area: 'hc',
+               room: 0x32,
+               chest: 0,
+       },
+       {
+               id: 'dp-big-chest',
+               area: 'dp',
+               room: 0x73,
+               chest: 0,
+       },
+       {
+               id: 'dp-big-key-chest',
+               area: 'dp',
+               room: 0x75,
+               chest: 0,
+       },
+       {
+               id: 'dp-compass-chest',
+               area: 'dp',
+               room: 0x85,
+               chest: 0,
+       },
+       {
+               id: 'dp-map-chest',
+               area: 'dp',
+               room: 0x74,
+               chest: 0,
+       },
+       {
+               id: 'dp-torch',
+               area: 'dp',
+               room: 0x73,
+               chest: 6,
+       },
+       {
+               id: 'ep-big-chest',
+               area: 'ep',
+               room: 0xA9,
+               chest: 0,
+       },
+       {
+               id: 'ep-big-key-chest',
+               area: 'ep',
+               room: 0xB8,
+               chest: 0,
+       },
+       {
+               id: 'ep-cannonball',
+               area: 'ep',
+               room: 0xB9,
+               chest: 0,
+       },
+       {
+               id: 'ep-compass-chest',
+               area: 'ep',
+               room: 0xA8,
+               chest: 0,
+       },
+       {
+               id: 'ep-map-chest',
+               area: 'ep',
+               room: 0xAA,
+               chest: 0,
+       },
+       {
+               id: 'flooded-chest',
+               room: 0x10B,
+               chest: 0,
+       },
+       {
+               id: 'graveyard-ledge',
+               room: 0x11B,
+               chest: 5,
+       },
+       {
+               id: 'gt-hope-left',
+               area: 'gt',
+               room: 0x8C,
+               chest: 1,
+       },
+       {
+               id: 'gt-hope-right',
+               area: 'gt',
+               room: 0x8C,
+               chest: 2,
+       },
+       {
+               id: 'gt-tile-room',
+               area: 'gt',
+               room: 0x8D,
+               chest: 0,
+       },
+       {
+               id: 'gt-compass-tl',
+               area: 'gt',
+               room: 0x9D,
+               chest: 0,
+       },
+       {
+               id: 'gt-compass-tr',
+               area: 'gt',
+               room: 0x9D,
+               chest: 1,
+       },
+       {
+               id: 'gt-compass-bl',
+               area: 'gt',
+               room: 0x9D,
+               chest: 2,
+       },
+       {
+               id: 'gt-compass-br',
+               area: 'gt',
+               room: 0x9D,
+               chest: 3,
+       },
+       {
+               id: 'gt-torch',
+               area: 'gt',
+               room: 0x8C,
+               chest: 6,
+       },
+       {
+               id: 'gt-dm-tl',
+               area: 'gt',
+               room: 0x7B,
+               chest: 0,
+       },
+       {
+               id: 'gt-dm-tr',
+               area: 'gt',
+               room: 0x7B,
+               chest: 1,
+       },
+       {
+               id: 'gt-dm-bl',
+               area: 'gt',
+               room: 0x7B,
+               chest: 2,
+       },
+       {
+               id: 'gt-dm-br',
+               area: 'gt',
+               room: 0x7B,
+               chest: 3,
+       },
+       {
+               id: 'gt-map-chest',
+               area: 'gt',
+               room: 0x8B,
+               chest: 0,
+       },
+       {
+               id: 'gt-firesnake',
+               area: 'gt',
+               room: 0x7D,
+               chest: 0,
+       },
+       {
+               id: 'gt-rando-tl',
+               area: 'gt',
+               room: 0x7C,
+               chest: 0,
+       },
+       {
+               id: 'gt-rando-tr',
+               area: 'gt',
+               room: 0x7C,
+               chest: 1,
+       },
+       {
+               id: 'gt-rando-bl',
+               area: 'gt',
+               room: 0x7C,
+               chest: 2,
+       },
+       {
+               id: 'gt-rando-br',
+               area: 'gt',
+               room: 0x7C,
+               chest: 3,
+       },
+       {
+               id: 'gt-bobs-chest',
+               area: 'gt',
+               room: 0x8C,
+               chest: 3,
+       },
+       {
+               id: 'gt-ice-left',
+               area: 'gt',
+               room: 0x1C,
+               chest: 1,
+       },
+       {
+               id: 'gt-ice-mid',
+               area: 'gt',
+               room: 0x1C,
+               chest: 0,
+       },
+       {
+               id: 'gt-ice-right',
+               area: 'gt',
+               room: 0x1C,
+               chest: 2,
+       },
+       {
+               id: 'gt-big-chest',
+               area: 'gt',
+               room: 0x8C,
+               chest: 0,
+       },
+       {
+               id: 'gt-helma-left',
+               area: 'gt',
+               room: 0x3D,
+               chest: 0,
+       },
+       {
+               id: 'gt-helma-right',
+               area: 'gt',
+               room: 0x3D,
+               chest: 1,
+       },
+       {
+               id: 'gt-pre-moldorm',
+               area: 'gt',
+               room: 0x3D,
+               chest: 2,
+       },
+       {
+               id: 'gt-post-moldorm',
+               area: 'gt',
+               room: 0x4D,
+               chest: 0,
+       },
+       {
+               id: 'hammer-pegs',
+               room: 0x127,
+               chest: 6,
+       },
+       {
+               id: 'hc-boom',
+               area: 'hc',
+               room: 0x71,
+               chest: 0,
+       },
+       {
+               id: 'hc-cell',
+               area: 'hc',
+               room: 0x80,
+               chest: 0,
+       },
+       {
+               id: 'hc-map',
+               area: 'hc',
+               room: 0x72,
+               chest: 0,
+       },
+       {
+               id: 'hookshot-cave-br',
+               room: 0x3C,
+               chest: 3,
+       },
+       {
+               id: 'hookshot-cave-tr',
+               room: 0x3C,
+               chest: 0,
+       },
+       {
+               id: 'hookshot-cave-tl',
+               room: 0x3C,
+               chest: 1,
+       },
+       {
+               id: 'hookshot-cave-bl',
+               room: 0x3C,
+               chest: 2,
+       },
+       {
+               id: 'hype-cave-top',
+               room: 0x11E,
+               chest: 0,
+       },
+       {
+               id: 'hype-cave-left',
+               room: 0x11E,
+               chest: 1,
+       },
+       {
+               id: 'hype-cave-right',
+               room: 0x11E,
+               chest: 2,
+       },
+       {
+               id: 'hype-cave-bottom',
+               room: 0x11E,
+               chest: 4,
+       },
+       {
+               id: 'hype-cave-npc',
+               room: 0x11E,
+               chest: 6,
+       },
+       {
+               id: 'ice-rod-cave',
+               room: 0x120,
+               chest: 0,
+       },
+       {
+               id: 'ip-compass-chest',
+               area: 'ip',
+               room: 0x2E,
+               chest: 0,
+       },
+       {
+               id: 'ip-big-key-chest',
+               area: 'ip',
+               room: 0x1F,
+               chest: 0,
+       },
+       {
+               id: 'ip-map-chest',
+               area: 'ip',
+               room: 0x3F,
+               chest: 0,
+       },
+       {
+               id: 'ip-spike-chest',
+               area: 'ip',
+               room: 0x5F,
+               chest: 0,
+       },
+       {
+               id: 'ip-freezor-chest',
+               area: 'ip',
+               room: 0x7E,
+               chest: 0,
+       },
+       {
+               id: 'ip-big-chest',
+               area: 'ip',
+               room: 0x9E,
+               chest: 0,
+       },
+       {
+               id: 'ip-ice-t',
+               area: 'ip',
+               room: 0xAE,
+               chest: 0,
+       },
+       {
+               id: 'kak-well-top',
+               room: 0x2F,
+               chest: 0,
+       },
+       {
+               id: 'kak-well-left',
+               room: 0x2F,
+               chest: 1,
+       },
+       {
+               id: 'kak-well-mid',
+               room: 0x2F,
+               chest: 2,
+       },
+       {
+               id: 'kak-well-right',
+               room: 0x2F,
+               chest: 3,
+       },
+       {
+               id: 'kak-well-bottom',
+               room: 0x2F,
+               chest: 4,
+       },
+       {
+               id: 'kings-tomb',
+               room: 0x113,
+               chest: 0,
+       },
+       {
+               id: 'links-house',
+               room: 0x104,
+               chest: 0,
+       },
+       {
+               id: 'lost-woods-hideout',
+               room: 0xE1,
+               chest: 5,
+       },
+       {
+               id: 'lumberjack',
+               room: 0xE2,
+               chest: 5,
+       },
+       {
+               id: 'mimic-cave',
+               room: 0x10C,
+               chest: 0,
+       },
+       {
+               id: 'mini-moldorm-far-left',
+               room: 0x123,
+               chest: 0,
+       },
+       {
+               id: 'mini-moldorm-left',
+               room: 0x123,
+               chest: 1,
+       },
+       {
+               id: 'mini-moldorm-right',
+               room: 0x123,
+               chest: 2,
+       },
+       {
+               id: 'mini-moldorm-far-right',
+               room: 0x123,
+               chest: 3,
+       },
+       {
+               id: 'mini-moldorm-npc',
+               room: 0x123,
+               chest: 6,
+       },
+       {
+               id: 'mm-bridge-chest',
+               room: 0xA2,
+               chest: 0,
+       },
+       {
+               id: 'mm-spike-chest',
+               room: 0xB3,
+               chest: 0,
+       },
+       {
+               id: 'mm-lobby-chest',
+               room: 0xC2,
+               chest: 0,
+       },
+       {
+               id: 'mm-compass-chest',
+               room: 0xC1,
+               chest: 0,
+       },
+       {
+               id: 'mm-big-key-chest',
+               room: 0xD1,
+               chest: 0,
+       },
+       {
+               id: 'mm-big-chest',
+               room: 0xC3,
+               chest: 0,
+       },
+       {
+               id: 'mm-map-chest',
+               room: 0xC3,
+               chest: 1,
+       },
+       {
+               id: 'mire-shed-left',
+               room: 0x10D,
+               chest: 0,
+       },
+       {
+               id: 'mire-shed-right',
+               room: 0x10D,
+               chest: 1,
+       },
+       {
+               id: 'paradox-lower-far-left',
+               room: 0xEF,
+               chest: 0,
+       },
+       {
+               id: 'paradox-lower-left',
+               room: 0xEF,
+               chest: 1,
+       },
+       {
+               id: 'paradox-lower-right',
+               room: 0xEF,
+               chest: 2,
+       },
+       {
+               id: 'paradox-lower-far-right',
+               room: 0xEF,
+               chest: 4,
+       },
+       {
+               id: 'paradox-lower-mid',
+               room: 0xEF,
+               chest: 5,
+       },
+       {
+               id: 'paradox-upper-left',
+               room: 0xFF,
+               chest: 0,
+       },
+       {
+               id: 'paradox-upper-right',
+               room: 0xFF,
+               chest: 1,
+       },
+       {
+               id: 'pd-shooter-room',
+               room: 0x09,
+               chest: 0,
+       },
+       {
+               id: 'pd-stalfos-basement',
+               room: 0x0A,
+               chest: 0,
+       },
+       {
+               id: 'pd-big-key-chest',
+               room: 0x3A,
+               chest: 0,
+       },
+       {
+               id: 'pd-arena-bridge',
+               room: 0x2A,
+               chest: 1,
+       },
+       {
+               id: 'pd-arena-ledge',
+               room: 0x2A,
+               chest: 0,
+       },
+       {
+               id: 'pd-map-chest',
+               room: 0x2B,
+               chest: 0,
+       },
+       {
+               id: 'pd-big-chest',
+               room: 0x1A,
+               chest: 0,
+       },
+       {
+               id: 'pd-compass-chest',
+               room: 0x1A,
+               chest: 1,
+       },
+       {
+               id: 'pd-harmless-hellway',
+               room: 0x1A,
+               chest: 2,
+       },
+       {
+               id: 'pd-maze-top',
+               room: 0x19,
+               chest: 0,
+       },
+       {
+               id: 'pd-maze-bottom',
+               room: 0x19,
+               chest: 1,
+       },
+       {
+               id: 'pd-basement-left',
+               room: 0x6A,
+               chest: 0,
+       },
+       {
+               id: 'pd-basement-right',
+               room: 0x6A,
+               chest: 1,
+       },
+       {
+               id: 'pyramid-fairy-left',
+               room: 0x116,
+               chest: 0,
+       },
+       {
+               id: 'pyramid-fairy-right',
+               room: 0x116,
+               chest: 1,
+       },
+       {
+               id: 'saha-left',
+               room: 0x105,
+               chest: 0,
+       },
+       {
+               id: 'saha-mid',
+               room: 0x105,
+               chest: 1,
+       },
+       {
+               id: 'saha-right',
+               room: 0x105,
+               chest: 2,
+       },
+       {
+               id: 'sanc',
+               area: 'hc',
+               room: 0x12,
+               chest: 0,
+       },
+       {
+               id: 'secret-passage',
+               room: 0x55,
+               chest: 0,
+       },
+       {
+               id: 'sewers-left',
+               area: 'hc',
+               room: 0x11,
+               chest: 0,
+       },
+       {
+               id: 'sewers-mid',
+               area: 'hc',
+               room: 0x11,
+               chest: 1,
+       },
+       {
+               id: 'sewers-right',
+               area: 'hc',
+               room: 0x11,
+               chest: 2,
+       },
+       {
+               id: 'sp-lobby',
+               area: 'sp',
+               room: 0x28,
+               chest: 0,
+       },
+       {
+               id: 'sp-map-chest',
+               area: 'sp',
+               room: 0x37,
+               chest: 0,
+       },
+       {
+               id: 'sp-big-chest',
+               area: 'sp',
+               room: 0x36,
+               chest: 0,
+       },
+       {
+               id: 'sp-compass-chest',
+               area: 'sp',
+               room: 0x46,
+               chest: 0,
+       },
+       {
+               id: 'sp-west-chest',
+               area: 'sp',
+               room: 0x34,
+               chest: 0,
+       },
+       {
+               id: 'sp-big-key-chest',
+               area: 'sp',
+               room: 0x35,
+               chest: 0,
+       },
+       {
+               id: 'sp-flooded-left',
+               area: 'sp',
+               room: 0x76,
+               chest: 0,
+       },
+       {
+               id: 'sp-flooded-right',
+               area: 'sp',
+               room: 0x76,
+               chest: 1,
+       },
+       {
+               id: 'sp-waterfall',
+               area: 'sp',
+               room: 0x66,
+               chest: 0,
+       },
+       {
+               id: 'spec-rock-cave',
+               room: 0xEA,
+               chest: 6,
+       },
+       {
+               id: 'spike-cave',
+               room: 0x117,
+               chest: 0,
+       },
+       {
+               id: 'spiral-cave',
+               room: 0xFE,
+               chest: 0,
+       },
+       {
+               id: 'super-bunny-top',
+               room: 0xF8,
+               chest: 0,
+       },
+       {
+               id: 'super-bunny-bottom',
+               room: 0xF8,
+               chest: 0,
+       },
+       {
+               id: 'sw-big-chest',
+               area: 'sw',
+               room: 0x58,
+               chest: 0,
+       },
+       {
+               id: 'sw-map-chest',
+               area: 'sw',
+               room: 0x58,
+               chest: 1,
+       },
+       {
+               id: 'sw-compass-chest',
+               area: 'sw',
+               room: 0x67,
+               chest: 0,
+       },
+       {
+               id: 'sw-big-key-chest',
+               area: 'sw',
+               room: 0x57,
+               chest: 0,
+       },
+       {
+               id: 'sw-pot-prison',
+               area: 'sw',
+               room: 0x57,
+               chest: 1,
+       },
+       {
+               id: 'sw-pinball-room',
+               area: 'sw',
+               room: 0x68,
+               chest: 0,
+       },
+       {
+               id: 'sw-bridge-chest',
+               area: 'sw',
+               room: 0x59,
+               chest: 0,
+       },
+       {
+               id: 'tavern',
+               room: 0x103,
+               chest: 0,
+       },
+       {
+               id: 'th-basement-cage',
+               area: 'th',
+               room: 0x87,
+               chest: 6,
+       },
+       {
+               id: 'th-big-key-chest',
+               area: 'th',
+               room: 0x87,
+               chest: 0,
+       },
+       {
+               id: 'th-map-chest',
+               area: 'th',
+               room: 0x77,
+               chest: 0,
+       },
+       {
+               id: 'th-big-chest',
+               area: 'th',
+               room: 0x27,
+               chest: 0,
+       },
+       {
+               id: 'th-compass-chest',
+               area: 'th',
+               room: 0x27,
+               chest: 1,
+       },
+       {
+               id: 'tr-roller-left',
+               area: 'tr',
+               room: 0xB7,
+               chest: 0,
+       },
+       {
+               id: 'tr-roller-right',
+               area: 'tr',
+               room: 0xB7,
+               chest: 1,
+       },
+       {
+               id: 'tr-compass-chest',
+               area: 'tr',
+               room: 0xD6,
+               chest: 0,
+       },
+       {
+               id: 'tr-chomps',
+               area: 'tr',
+               room: 0xB6,
+               chest: 0,
+       },
+       {
+               id: 'tr-big-key-chest',
+               area: 'tr',
+               room: 0x14,
+               chest: 0,
+       },
+       {
+               id: 'tr-big-chest',
+               area: 'tr',
+               room: 0x24,
+               chest: 0,
+       },
+       {
+               id: 'tr-crysta-roller',
+               area: 'tr',
+               room: 0x04,
+               chest: 0,
+       },
+       {
+               id: 'tr-laser-bridge-top',
+               area: 'tr',
+               room: 0xD5,
+               chest: 0,
+       },
+       {
+               id: 'tr-laser-bridge-left',
+               area: 'tr',
+               room: 0xD5,
+               chest: 1,
+       },
+       {
+               id: 'tr-laser-bridge-right',
+               area: 'tr',
+               room: 0xD5,
+               chest: 2,
+       },
+       {
+               id: 'tr-laser-bridge-bottom',
+               area: 'tr',
+               room: 0xD5,
+               chest: 3,
+       },
+       {
+               id: 'tt-map-chest',
+               area: 'tt',
+               room: 0xDB,
+               chest: 0,
+       },
+       {
+               id: 'tt-big-key-chest',
+               area: 'tt',
+               room: 0xDB,
+               chest: 1,
+       },
+       {
+               id: 'tt-ambush-chest',
+               area: 'tt',
+               room: 0xCB,
+               chest: 0,
+       },
+       {
+               id: 'tt-compass-chest',
+               area: 'tt',
+               room: 0xDC,
+               chest: 0,
+       },
+       {
+               id: 'tt-attic',
+               area: 'tt',
+               room: 0x65,
+               chest: 0,
+       },
+       {
+               id: 'tt-cell',
+               area: 'tt',
+               room: 0x45,
+               chest: 0,
+       },
+       {
+               id: 'tt-big-chest',
+               area: 'tt',
+               room: 0x44,
+               chest: 0,
+       },
+       {
+               id: 'waterfall-fairy-left',
+               room: 0x114,
+               chest: 4,
+       },
+       {
+               id: 'waterfall-fairy-right',
+               room: 0x114,
+               chest: 5,
+       },
+];
+
+export const toggleBoolean = name => state => ({
+       ...state,
+       [name]: !state[name],
+});
+
+export const increment = (name, max, skipZero) => state => {
+       let newValue = ((state[name] || 0) + 1) % (max + 1);
+       if (skipZero && !newValue) {
+               newValue = 1;
+       }
+       return {
+               ...state,
+               [name]: newValue,
+       };
+};
+
+export const decrement = (name, max, skipZero) => state => {
+       let newValue = ((state[name] || 0) + max) % (max + 1);
+       if (skipZero && !newValue) {
+               newValue = max;
+       }
+       return {
+               ...state,
+               [name]: newValue,
+       };
+};
+
+export const highestActive = (state, names) => {
+       for (let i = names.length; i >= 0; --i) {
+               if (state[names[i]]) {
+                       return names[i];
+               }
+       }
+       return null;
+};
+
+export const hasDungeonBoss = (state, dungeon) => !!state[`${dungeon.id}-boss-defeated`];
+
+export const getDungeonBoss = (state, dungeon) =>
+       state[`${dungeon.id}-boss`] || dungeon.boss || null;
+
+export const hasDungeonPrize = (state, dungeon) => !!state[`${dungeon.id}-prize-acquired`];
+
+export const getDungeonPrize = (state, dungeon) => state[`${dungeon.id}-prize`] || null;
+
+export const makeEmptyState = () => {
+       const state = {};
+       BOOLEAN_STATES.forEach(p => {
+               state[p] = INITIAL[p] || false;
+       });
+       INTEGER_STATES.forEach(p => {
+               state[p] = INITIAL[p] || 0;
+       });
+       DUNGEONS.forEach(dungeon => {
+               state[`${dungeon.id}-map`] = false;
+               state[`${dungeon.id}-compass`] = false;
+               state[`${dungeon.id}-small-key`] = 0;
+               state[`${dungeon.id}-big-key`] = false;
+               state[`${dungeon.id}-checks`] = 0;
+               if (dungeon.boss) {
+                       state[`${dungeon.id}-boss`] = dungeon.boss;
+                       state[`${dungeon.id}-boss-defeated`] = false;
+               }
+               if (dungeon.prize) {
+                       state[`${dungeon.id}-prize`] = 'crystal';
+                       state[`${dungeon.id}-prize-acquired`] = false;
+               }
+       });
+       return state;
+};
+
+const collectInventory = (state, data, prizeMap) => {
+       state.bow = !!(data[INV_ADDR.RANDO_BOW] & 0x80);
+       state.silvers = (data[INV_ADDR.RANDO_BOW] & 0xC0) == 0xC0;
+       state['bowless-silvers'] = (data[INV_ADDR.RANDO_BOW] & 0xC0) == 0x40;
+       state['blue-boomerang'] = !!(data[INV_ADDR.RANDO_BOOM] & 0x40);
+       state['red-boomerang'] = !!(data[INV_ADDR.RANDO_BOOM] & 0x80);
+       state.hookshot = !!data[INV_ADDR.HOOK];
+       state.bomb = data[INV_ADDR.BOMB];
+       state.mushroom = !!(data[INV_ADDR.RANDO_POWDER] & 0x20);
+       state.powder = !!(data[INV_ADDR.RANDO_POWDER] & 0x10);
+       state['fire-rod'] = !!data[INV_ADDR.FROD];
+       state['ice-rod'] = !!data[INV_ADDR.IROD];
+       state.bombos = !!data[INV_ADDR.BOMBOS];
+       state.ether = !!data[INV_ADDR.ETHER];
+       state.quake = !!data[INV_ADDR.QUAKE];
+       state.lamp = !!data[INV_ADDR.LAMP];
+       state.hammer = !!data[INV_ADDR.HAMMER];
+       state.shovel = !!(data[INV_ADDR.RANDO_FLUTE] & 0x04);
+       state.flute = !!(data[INV_ADDR.RANDO_FLUTE] & 0x03);
+       state.duck = !!(data[INV_ADDR.RANDO_FLUTE] & 0x01);
+       state.bugnet = !!data[INV_ADDR.BUGNET];
+       state.book = !!data[INV_ADDR.BOOK];
+       state.bottle = 0;
+       if (data[INV_ADDR.BOTTLE_1]) {
+               ++state.bottle;
+       }
+       if (data[INV_ADDR.BOTTLE_2]) {
+               ++state.bottle;
+       }
+       if (data[INV_ADDR.BOTTLE_3]) {
+               ++state.bottle;
+       }
+       if (data[INV_ADDR.BOTTLE_4]) {
+               ++state.bottle;
+       }
+       state.somaria = !!data[INV_ADDR.SOMARIA];
+       state.byrna = !!data[INV_ADDR.BYRNA];
+       state.cape = !!data[INV_ADDR.CAPE];
+       state.mirror = !!data[INV_ADDR.MIRROR];
+       state.lift = data[INV_ADDR.GLOVE];
+       state.boots = !!data[INV_ADDR.BOOTS];
+       state.flippers = !!data[INV_ADDR.FLIPPERS];
+       state.moonpearl = !!data[INV_ADDR.MOONPEARL];
+       state.sword = data[INV_ADDR.SWORD];
+       state.shield = data[INV_ADDR.SHIELD];
+       state.mail = data[INV_ADDR.ARMOR] + 1;
+       state['heart-piece'] = data[INV_ADDR.HEART_PIECE];
+       state['half-magic'] = data[INV_ADDR.MAGIC_USE] > 0;
+       state['quarter-magic'] = data[INV_ADDR.MAGIC_USE] > 1;
+       const map = getShort(data, INV_ADDR.MAP);
+       const compass = getShort(data, INV_ADDR.COMPASS);
+       const bigKey = getShort(data, INV_ADDR.BIG_KEY);
+       DUNGEONS.forEach(dungeon => {
+               state[`${dungeon.id}-map`] = !!(map & dungeon.mask);
+               state[`${dungeon.id}-compass`] = !!(compass & dungeon.mask);
+               state[`${dungeon.id}-small-key`] = data[INV_ADDR.RANDO_KEY_START + dungeon.offset];
+               state[`${dungeon.id}-big-key`] = !!(bigKey & dungeon.mask);
+               if (dungeon.prize) {
+                       const isCrystal = prizeMap[dungeon.offset].isCrystal;
+                       const prizeFlags = data[isCrystal ? INV_ADDR.CRYSTALS : INV_ADDR.PENDANTS];
+                       state[`${dungeon.id}-prize-acquired`] = !!(prizeFlags & prizeMap[dungeon.offset].mask);
+               }
+       });
+};
+
+const collectOverworld = (state, data) => {
+       OVERWORLD_LOCATIONS.forEach(location => {
+               state[location.id] = !!(data[location.address] & location.mask);
+       });
+};
+
+const collectUnderworld = (state, data) => {
+       UNDERWORLD_LOCATIONS.forEach(location => {
+               state[location.id] = isChestOpen(data, location.room, location.chest);
+       });
+       DUNGEONS.forEach(dungeon => {
+               state[`${dungeon.id}-boss-defeated`] = isBossDefeated(data, dungeon.bossRoom);
+       });
+};
+
+export const computeState = (data, prizeMap) => {
+       const state = {};
+       collectInventory(state, data.slice(SRAM_ADDR.INV_START), prizeMap);
+       collectOverworld(state, data);
+       collectUnderworld(state, data.slice(SRAM_ADDR.ROOM_DATA_START));
+       return state;
+};
+
+const getDungeonAmounts = (config, state) => {
+       const amounts = {};
+       DUNGEONS.forEach(dungeon => {
+               let amount = 0;
+               let total = dungeon.checks.length;
+               dungeon.checks.forEach(check => {
+                       if (state[check]) {
+                               ++amount;
+                       }
+               });
+               if (!config.wildMap && state[`${dungeon.id}-map`]) {
+                       --amount;
+                       --total;
+               }
+               if (!config.wildCompass && state[`${dungeon.id}-compass`]) {
+                       --amount;
+                       --total;
+               }
+               if (!config.wildSmall) {
+                       amount -= Math.min(state[`${dungeon.id}-small-key`], dungeon.sk);
+                       total -= dungeon.sk;
+               }
+               if (!config.wildBig && !dungeon.dropBk && state[`${dungeon.id}-big-key`]) {
+                       --amount;
+                       --total;
+               }
+               amounts[dungeon.id] = Math.min(total, amount);
+       });
+       return amounts;
+};
+
+export const mergeStates = (config, cur, inc) => {
+       const next = { ...cur, ...inc };
+       const amounts = getDungeonAmounts(config, inc);
+       DUNGEONS.forEach(dungeon => {
+               next[`${dungeon.id}-checks`] = amounts[dungeon.id];
+       });
+       //console.log(next);
+       return next;
+};
diff --git a/resources/js/hooks/tracker.js b/resources/js/hooks/tracker.js
new file mode 100644 (file)
index 0000000..be5a52f
--- /dev/null
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { CONFIG, DUNGEONS, makeEmptyState } from '../helpers/tracker';
+
+const context = React.createContext({});
+
+export const useTracker = () => React.useContext(context);
+
+export const TrackerProvider = ({ children }) => {
+       const [config, setConfig] = React.useState(CONFIG);
+       const [state, setState] = React.useState(makeEmptyState());
+       const [dungeons, setDungeons] = React.useState(DUNGEONS);
+
+       React.useEffect(() => {
+               const newDungeons = DUNGEONS.map(dungeon => {
+                       const newDungeon = JSON.parse(JSON.stringify(dungeon));
+                       if (config.wildMap && dungeon.map) {
+                               ++newDungeon.items;
+                       }
+                       if (config.wildCompass && dungeon.compass) {
+                               ++newDungeon.items;
+                       }
+                       if (config.wildSmall) {
+                               newDungeon.items += dungeon.sk;
+                       }
+                       if (config.wildBig && dungeon.bk && !dungeon.dropBk) {
+                               ++newDungeon.items;
+                       }
+                       if (!config.bossShuffle && dungeon.boss) {
+                               newDungeon.bosses = [dungeon.boss];
+                       }
+                       return newDungeon;
+               });
+               setDungeons(newDungeons);
+       }, [config]);
+
+       const value = React.useMemo(() => {
+               return { config, setConfig, dungeons, setState, state };
+       }, [config, dungeons, state]);
+
+       return <context.Provider value={value}>
+               {children}
+       </context.Provider>;
+};
+
+TrackerProvider.propTypes = {
+       children: PropTypes.node,
+};
index b8442dcd3d63f4eaf2d1ef3561a28f538582c075..3b6240c07e806254f316046647dbaa95f7b455be 100644 (file)
@@ -242,6 +242,7 @@ export default {
                                'bottle-bee': 'Bee in a Bottle',
                                bottle: 'Bottle',
                                bow: 'Bow',
+                               'bowless-silvers': 'Silvers ohne Bow',
                                bugnet: 'Bugnet',
                                byrna: 'Cane of Byrna',
                                cape: 'Cape',
@@ -270,9 +271,11 @@ export default {
                                flippers: 'Flippers',
                                flute: 'Flute',
                                glove: 'Power Glove',
+                               'gold-sword': 'Gold Sword',
                                'green-mail': 'Green Mail',
                                'green-pendant': 'Pendant of Courage',
                                'green-potion': 'Green Potion',
+                               'half-magic': 'Half Magic',
                                hammer: 'Hammer',
                                'heart-container': 'Heart Container',
                                'heart-piece': 'Heart Piece',
@@ -280,6 +283,7 @@ export default {
                                'ice-rod': 'Ice Rod',
                                lamp: 'Lamp',
                                map: 'Map',
+                               'master-sword': 'Master Sword',
                                mirror: 'Mirror',
                                'mirror-shield': 'Mirror Shield',
                                mitts: 'Titan \'s Mitts',
@@ -290,6 +294,7 @@ export default {
                                'not-moonpearl': 'Keine Moonpearl',
                                powder: 'Powder',
                                quake: 'Quake',
+                               'quarter-magic': 'Quarter Magic',
                                'red-bomb': 'Red Bomb',
                                'red-boomerang': 'Red Boomerang',
                                'red-mail': 'Red Mail',
@@ -299,6 +304,7 @@ export default {
                                silvers: 'Silvers',
                                'small-key': 'Small Key',
                                somaria: 'Cane of Somaria',
+                               'tempered-sword': 'Tempered Sword',
                        },
                },
                map: {
index c5efdf45b5ba552712291a5a8eecf4decc465cc7..b4acc0cf6e935861659c993d3ce3f2b9bb0a51ff 100644 (file)
@@ -242,6 +242,7 @@ export default {
                                'bottle-bee': 'Bee in a Bottle',
                                bottle: 'Bottle',
                                bow: 'Bow',
+                               'bowless-silvers': 'Silvers w/o Bow',
                                bugnet: 'Bugnet',
                                byrna: 'Cane of Byrna',
                                cape: 'Cape',
@@ -270,9 +271,11 @@ export default {
                                flippers: 'Flippers',
                                flute: 'Flute',
                                glove: 'Power Glove',
+                               'gold-sword': 'Gold Sword',
                                'green-mail': 'Green Mail',
                                'green-pendant': 'Pendant of Courage',
                                'green-potion': 'Green Potion',
+                               'half-magic': 'Half Magic',
                                hammer: 'Hammer',
                                'heart-container': 'Heart Container',
                                'heart-piece': 'Heart Piece',
@@ -280,6 +283,7 @@ export default {
                                'ice-rod': 'Ice Rod',
                                lamp: 'Lamp',
                                map: 'Map',
+                               'master-sword': 'Master Sword',
                                mirror: 'Mirror',
                                'mirror-shield': 'Mirror Shield',
                                mitts: 'Titan \'s Mitts',
@@ -290,6 +294,7 @@ export default {
                                'not-moonpearl': 'No Moonpearl',
                                powder: 'Powder',
                                quake: 'Quake',
+                               'quarter-magic': 'Quarter Magic',
                                'red-bomb': 'Red Bomb',
                                'red-boomerang': 'Red Boomerang',
                                'red-mail': 'Red Mail',
@@ -299,6 +304,7 @@ export default {
                                silvers: 'Silvers',
                                'small-key': 'Small Key',
                                somaria: 'Cane of Somaria',
+                               'tempered-sword': 'Tempered Sword',
                        },
                },
                map: {
diff --git a/resources/js/pages/Tracker.js b/resources/js/pages/Tracker.js
new file mode 100644 (file)
index 0000000..eccd766
--- /dev/null
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Tracker from '../components/tracker';
+import { TrackerProvider } from '../hooks/tracker';
+
+export const Component = () => {
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>Tracker</title>
+               </Helmet>
+               <TrackerProvider>
+                       <Tracker />
+               </TrackerProvider>
+       </ErrorBoundary>;
+};
index 841390814b7f77a6bca1e305c9b545a03a97d35e..7554a9e74dbc0769ca31f85bbb41bfc8fe83fee2 100644 (file)
@@ -19,4 +19,5 @@
 @import 'rounds';
 @import 'techniques';
 @import 'tournaments';
+@import 'tracker';
 @import 'users';
index 0cb6655c981dd8046b2f040c7dc5f1186acde644..8fe76bbf588ac5c871dff2d3690e0b2ed34441f6 100644 (file)
@@ -258,6 +258,7 @@ h1 {
        position: relative;
        display: inline-flex;
        align-items: center;
+       vertical-align: middle;
        width: 2em;
        height: 2em;
 
@@ -266,6 +267,13 @@ h1 {
                max-width: 100%;
                max-height: 100%;
        }
+       .item-map-icon {
+               display: inline-block;
+               width: 100%;
+               height: 100%;
+               background: url(/items-v1.png);
+               background-size: 800% 1100%;
+       }
        .strike {
                position: absolute;
                top: 0;
diff --git a/resources/sass/tracker.scss b/resources/sass/tracker.scss
new file mode 100644 (file)
index 0000000..06f1577
--- /dev/null
@@ -0,0 +1,130 @@
+.tracker {
+       .count-display {
+               background: black;
+               font-weight: bold;
+               text-align: center;
+       }
+       .dungeon {
+               display: flex;
+               flex-direction: row;
+               align-items: stretch;
+               justify-content: flex-start;
+               gap: 1ex;
+               > * {
+                       width: 2em;
+                       height: 2em;
+               }
+               .dungeon-smalls .count-display,
+               .dungeon-tag {
+                       background: black;
+                       font-family: monospace;
+                       font-size: 115%;
+                       font-weight: bold;
+                       text-align: center;
+               }
+               .dungeon-checks,
+               .dungeon-smalls {
+                       position: relative;
+                       .count-display {
+                               pointer-events: none;
+                               position: absolute;
+                               top: .3ex;
+                               left: .3ex;
+                               bottom: .3ex;
+                               right: .3ex;
+                               &.is-zero {
+                                       display: none;
+                               }
+                       }
+               }
+       }
+       .dungeon-ep,
+       .dungeon-pd {
+               margin-top: 1ex;
+       }
+       .equipment {
+               display: grid;
+               grid-template-columns: 3em 3em 3em 3em 3em;
+               gap: 1ex;
+               padding: 1ex;
+       }
+       .items {
+               display: grid;
+               grid-template-columns: 3em 3em 3em 3em 3em;
+               gap: 1ex;
+               padding: 1ex;
+       }
+       .item {
+               position: relative;
+               width: 3em;
+               height: 3em;
+
+               .bottom-left,
+               .bottom-right,
+               .top-left,
+               .top-right {
+                       position: absolute;
+                       width: 50%;
+                       height: 50%;
+                       .zelda-icon {
+                               transform: scale(1.4);
+                       }
+               }
+               .bottom-left {
+                       bottom: 0;
+                       left: 0;
+               }
+               .bottom-right {
+                       bottom: 0;
+                       right: 0;
+               }
+               .top-left {
+                       top: 0;
+                       left: 0;
+               }
+               .top-right {
+                       top: 0;
+                       right: 0;
+               }
+
+               .left,
+               .right {
+                       position: absolute;
+                       width: 50%;
+                       height: 100%;
+                       overflow: hidden;
+                       .zelda-icon {
+                               width: 200%;
+                               margin-left: -50%;
+                       }
+               }
+               .left {
+                       left: 0;
+               }
+               .right {
+                       right: 0;
+               }
+               .count-display {
+                       pointer-events: none;
+                       &.is-zero {
+                               display: none;
+                       }
+               }
+       }
+       .toggle-icon {
+               &.inactive {
+                       opacity: .5;
+               }
+       }
+       .tracker-toolbar {
+               .toggle-icon {
+                       display: inline-block;
+                       width: 2em;
+                       height: 2em;
+               }
+       }
+       .zelda-icon {
+               width: 100%;
+               height: 100%;
+       }
+}