+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+ compareGTBasementState,
+ countGTBasementState,
+ getGTBasementState,
+} 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
+ 0x04, // fast ganon (the default and used for defeat ganon for some reason)
+ 0x07, // crystals & bosses
+ 0x08, // bosses
+ 0x09, // all dungeons, no aga 1
+ 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 }) => {
+ const [enabled, setEnabled] = React.useState(false);
+ const controls = React.useRef({
+ onSolve,
+ onStart,
+ onStop,
+ });
+
+ const [inGame, setInGame] = React.useState(false);
+ const [seedType, setSeedType] = React.useState(0);
+ const [gtCrystals, setGTCrystals] = React.useState(0);
+ const [ganonType, setGanonType] = React.useState(0);
+ const [freeItemMenu, setFreeItemMenu] = React.useState(0);
+ const [pyramidOpen, setPyramidOpen] = React.useState(false);
+
+ const [ownedCrystals, setOwnedCrystals] = React.useState(0);
+ const [lastEntrance, setLastEntrance] = React.useState(0);
+ const [hasEntered, setHasEntered] = React.useState(false);
+ const [basement, setBasement] = React.useState({
+ state: getGTBasementState(),
+ last: '',
+ count: 0,
+ torch: 0,
+ });
+ const [hasBigKey, setHasBigKey] = React.useState(false);
+
+ const {
+ disable: disableSNES,
+ enable: enableSNES,
+ sock,
+ status,
+ } = useSNES();
+ const { t } = useTranslation();
+
+ React.useEffect(() => {
+ controls.current = {
+ onSolve,
+ onStart,
+ onStop,
+ };
+ }, [onSolve, onStart, onStop]);
+
+ const resetState = React.useCallback(() => {
+ setInGame(false);
+ setSeedType(0);
+ setGTCrystals(0);
+ setGanonType(0);
+ setFreeItemMenu(0);
+
+ setOwnedCrystals(0);
+ setLastEntrance(0);
+ setHasEntered(false);
+ setBasement({
+ state: getGTBasementState(),
+ last: '',
+ count: 0,
+ torch: 0,
+ });
+ setHasBigKey(false);
+ }, []);
+
+ const enable = React.useCallback(() => {
+ enableSNES();
+ setEnabled(true);
+ }, []);
+
+ const disable = React.useCallback(() => {
+ disableSNES();
+ setEnabled(false);
+ resetState();
+ }, []);
+
+ React.useEffect(() => {
+ const savedSettings = localStorage.getItem('guessingGame.settings');
+ if (savedSettings) {
+ const settings = JSON.parse(savedSettings);
+ if (settings.autoTrack) {
+ enable();
+ }
+ }
+ }, []);
+
+ const saveSettings = React.useCallback((newSettings) => {
+ const savedSettings = localStorage.getItem('guessingGame.settings');
+ const settings = savedSettings
+ ? { ...JSON.parse(savedSettings), ...newSettings }
+ : newSettings;
+ localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
+ }, []);
+
+ const toggle = React.useCallback(() => {
+ if (enabled) {
+ disable();
+ saveSettings({ autoTrack: false });
+ } else {
+ enable();
+ saveSettings({ autoTrack: true });
+ }
+ }, [enabled]);
+
+ // game mode timer
+ React.useEffect(() => {
+ if (enabled && !status.error && status.connected && status.device) {
+ const checkInGame = () => {
+ sock.current.readWRAM(GAME_MODE, 1, (data) => {
+ setInGame(IN_GAME_MODES.includes(data[0]));
+ });
+ };
+ checkInGame();
+ const timer = setInterval(checkInGame, 5000);
+ return () => {
+ clearInterval(timer);
+ };
+ } else {
+ setInGame(false);
+ }
+ }, [enabled && !status.error && status.connected && status.device]);
+
+ // refresh static game information
+ React.useEffect(() => {
+ if (!inGame) return;
+ sock.current.readBytes(SEED_TYPE, 1, (data) => {
+ setSeedType(data[0]);
+ });
+ sock.current.readBytes(GT_CRYSTALS, 1, (data) => {
+ setGTCrystals(data[0]);
+ });
+ sock.current.readBytes(GANON_TYPE, 1, (data) => {
+ setGanonType(data[0]);
+ });
+ sock.current.readBytes(FREE_ITEM_MENU, 1, (data) => {
+ setFreeItemMenu(data[0]);
+ });
+ sock.current.readBytes(INIT_SRAM + PYRAMID_SCREEN, 1, (data) => {
+ setPyramidOpen(!!(data[0] & 0x20));
+ });
+ }, [inGame, sock]);
+
+ const applicable = React.useMemo(() => {
+ return !seedType &&
+ gtCrystals &&
+ GT_TYPES.includes(ganonType) &&
+ !pyramidOpen &&
+ !(freeItemMenu & 0x02);
+ }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
+
+ // update crystals information
+ React.useEffect(() => {
+ if (!applicable || !inGame || hasBigKey) return;
+ const updateCrystals = () => {
+ sock.current.readWRAM(SAVE_WRAM + OWNED_CRYSTALS, 1, (data) => {
+ let owned = 0;
+ for (let i = 0; i < 7; ++i) {
+ if (data[0] & Math.pow(2, i)) {
+ ++owned;
+ }
+ }
+ setOwnedCrystals(owned);
+ });
+ };
+ // increase frequency for the last
+ const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
+
+ // start game once all required crystals have been acquired
+ React.useEffect(() => {
+ if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
+ controls.current.onStart();
+ const updateDungeon = () => {
+ sock.current.readWRAM(CURRENT_DUNGEON, 2, (data) => {
+ setLastEntrance(data[0] + (data[1] * 256));
+ });
+ };
+ const timer = setInterval(updateDungeon, 1000);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
+
+ // stop game when GT has been entered
+ React.useEffect(() => {
+ if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
+ if (lastEntrance === GT_ENTRANCE_ID) {
+ controls.current.onStop();
+ setHasEntered(true);
+ }
+ }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
+
+ // watch GT state
+ 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 gtState = getGTBasementState(data);
+ const gtCount = countGTBasementState(gtState);
+ setBasement(old => {
+ const cmp = compareGTBasementState(old.state, gtState);
+ if (cmp) {
+ return {
+ state: gtState,
+ last: cmp,
+ count: gtCount,
+ torch: cmp === 'torchSeen' ? gtCount : old.torch,
+ };
+ }
+ return old;
+ });
+ });
+ };
+ const timer = setInterval(updateGTState, 500);
+ return () => {
+ clearInterval(timer);
+ };
+ }, [applicable, hasBigKey, hasEntered]);
+
+ React.useEffect(() => {
+ if (!applicable) return;
+ if (hasBigKey) {
+ const solution = basement.last === 'torch' ? basement.torch : basement.count;
+ controls.current.onSolve(solution);
+ } else {
+ sock.current.readWRAM(SAVE_WRAM + BIG_KEYS_1, 1, (data) => {
+ setHasBigKey(!!(data[0] & 0x04));
+ });
+ }
+ }, [applicable, basement, controls, hasBigKey]);
+
+ const statusMsg = React.useMemo(() => {
+ if (!enabled) {
+ return 'disabled';
+ }
+ if (status.error) {
+ return 'error';
+ }
+ if (!status.connected) {
+ return 'disconnected';
+ }
+ if (!status.device) {
+ return 'no-device';
+ }
+ if (!inGame) {
+ return 'not-in-game';
+ }
+ if (!applicable) {
+ return 'not-applicable';
+ }
+ return 'tracking';
+ }, [applicable, enabled, inGame, 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}
+ <ToggleSwitch
+ onChange={toggle}
+ title={t('autoTracking.heading')}
+ value={enabled}
+ />
+ </div>;
+};
+
+GuessingGameAutoTracking.propTypes = {
+ onSolve: PropTypes.func,
+ onStart: PropTypes.func,
+ onStop: PropTypes.func,
+};
+
+export default GuessingGameAutoTracking;