]> git.localhorst.tv Git - alttp.git/blobdiff - resources/js/components/twitch-bot/GuessingGameAutoTracking.js
guessing game auto-tracking
[alttp.git] / resources / js / components / twitch-bot / GuessingGameAutoTracking.js
diff --git a/resources/js/components/twitch-bot/GuessingGameAutoTracking.js b/resources/js/components/twitch-bot/GuessingGameAutoTracking.js
new file mode 100644 (file)
index 0000000..fae142c
--- /dev/null
@@ -0,0 +1,346 @@
+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;