]> git.localhorst.tv Git - alttp.git/blob - resources/js/components/twitch-bot/GuessingGameAutoTracking.js
fae142cb9b1628068f1affb3368cc97071953aad
[alttp.git] / resources / js / components / twitch-bot / GuessingGameAutoTracking.js
1 import PropTypes from 'prop-types';
2 import React from 'react';
3 import { useTranslation } from 'react-i18next';
4
5 import Icon from '../common/Icon';
6 import ToggleSwitch from '../common/ToggleSwitch';
7 import {
8         compareGTBasementState,
9         countGTBasementState,
10         getGTBasementState,
11 } from '../../helpers/alttp-ram';
12 import { useSNES } from '../../hooks/snes';
13
14 const IN_GAME_MODES = [
15         0x05, // loading game
16         0x06, // entering dungeon
17         0x07, // dungeon
18         0x08, // entering overworld
19         0x09, // overworld
20         0x0A, // entering special overworld
21         0x0B, // special overworld
22         0x0E, // text/menu/map
23         0x0F, // closing spot
24         0x10, // opening spot
25         0x11, // falling
26         0x12, // dying
27         0x13, // fanfare
28         0x15, // mirror
29         0x16, // refill
30         0x17, // S&Q
31         0x18, // aga 2 cutscene
32         0x19, // triforce room
33         0x1A, // credits
34         0x1B, // spawn select
35 ];
36
37 const GT_TYPES = [
38         0x02, // all dungeons
39         0x03, // defeat ganon
40         0x04, // fast ganon (the default and used for defeat ganon for some reason)
41         0x07, // crystals & bosses
42         0x08, // bosses
43         0x09, // all dungeons, no aga 1
44         0x0B, // completionist
45 ];
46
47 const FREE_ITEM_MENU = 0x180045;
48 const GT_CRYSTALS = 0x18019A;
49 const GANON_TYPE = 0x1801A8;
50 const SEED_TYPE = 0x180210;
51 const INIT_SRAM = 0x183000;
52
53 const GAME_MODE = 0x10;
54 const CURRENT_DUNGEON = 0x10E;
55 const SAVE_WRAM = 0xF000;
56 const ROOM_DATA_START = 0x000;
57 const ROOM_DATA_END = 0x140;
58 const PYRAMID_SCREEN = 0x2DB;
59 const BIG_KEYS_1 = 0x366;
60 const OWNED_CRYSTALS = 0x37A;
61
62 const GT_ENTRANCE_ID = 55;
63
64 const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
65         const [enabled, setEnabled] = React.useState(false);
66         const controls = React.useRef({
67                 onSolve,
68                 onStart,
69                 onStop,
70         });
71
72         const [inGame, setInGame] = React.useState(false);
73         const [seedType, setSeedType] = React.useState(0);
74         const [gtCrystals, setGTCrystals] = React.useState(0);
75         const [ganonType, setGanonType] = React.useState(0);
76         const [freeItemMenu, setFreeItemMenu] = React.useState(0);
77         const [pyramidOpen, setPyramidOpen] = React.useState(false);
78
79         const [ownedCrystals, setOwnedCrystals] = React.useState(0);
80         const [lastEntrance, setLastEntrance] = React.useState(0);
81         const [hasEntered, setHasEntered] = React.useState(false);
82         const [basement, setBasement] = React.useState({
83                 state: getGTBasementState(),
84                 last: '',
85                 count: 0,
86                 torch: 0,
87         });
88         const [hasBigKey, setHasBigKey] = React.useState(false);
89
90         const {
91                 disable: disableSNES,
92                 enable: enableSNES,
93                 sock,
94                 status,
95         } = useSNES();
96         const { t } = useTranslation();
97
98         React.useEffect(() => {
99                 controls.current = {
100                         onSolve,
101                         onStart,
102                         onStop,
103                 };
104         }, [onSolve, onStart, onStop]);
105
106         const resetState = React.useCallback(() => {
107                 setInGame(false);
108                 setSeedType(0);
109                 setGTCrystals(0);
110                 setGanonType(0);
111                 setFreeItemMenu(0);
112
113                 setOwnedCrystals(0);
114                 setLastEntrance(0);
115                 setHasEntered(false);
116                 setBasement({
117                         state: getGTBasementState(),
118                         last: '',
119                         count: 0,
120                         torch: 0,
121                 });
122                 setHasBigKey(false);
123         }, []);
124
125         const enable = React.useCallback(() => {
126                 enableSNES();
127                 setEnabled(true);
128         }, []);
129
130         const disable = React.useCallback(() => {
131                 disableSNES();
132                 setEnabled(false);
133                 resetState();
134         }, []);
135
136         React.useEffect(() => {
137                 const savedSettings = localStorage.getItem('guessingGame.settings');
138                 if (savedSettings) {
139                         const settings = JSON.parse(savedSettings);
140                         if (settings.autoTrack) {
141                                 enable();
142                         }
143                 }
144         }, []);
145
146         const saveSettings = React.useCallback((newSettings) => {
147                 const savedSettings = localStorage.getItem('guessingGame.settings');
148                 const settings = savedSettings
149                         ? { ...JSON.parse(savedSettings), ...newSettings }
150                         : newSettings;
151                 localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
152         }, []);
153
154         const toggle = React.useCallback(() => {
155                 if (enabled) {
156                         disable();
157                         saveSettings({ autoTrack: false });
158                 } else {
159                         enable();
160                         saveSettings({ autoTrack: true });
161                 }
162         }, [enabled]);
163
164         // game mode timer
165         React.useEffect(() => {
166                 if (enabled && !status.error && status.connected && status.device) {
167                         const checkInGame = () => {
168                                 sock.current.readWRAM(GAME_MODE, 1, (data) => {
169                                         setInGame(IN_GAME_MODES.includes(data[0]));
170                                 });
171                         };
172                         checkInGame();
173                         const timer = setInterval(checkInGame, 5000);
174                         return () => {
175                                 clearInterval(timer);
176                         };
177                 } else {
178                         setInGame(false);
179                 }
180         }, [enabled && !status.error && status.connected && status.device]);
181
182         // refresh static game information
183         React.useEffect(() => {
184                 if (!inGame) return;
185                 sock.current.readBytes(SEED_TYPE, 1, (data) => {
186                         setSeedType(data[0]);
187                 });
188                 sock.current.readBytes(GT_CRYSTALS, 1, (data) => {
189                         setGTCrystals(data[0]);
190                 });
191                 sock.current.readBytes(GANON_TYPE, 1, (data) => {
192                         setGanonType(data[0]);
193                 });
194                 sock.current.readBytes(FREE_ITEM_MENU, 1, (data) => {
195                         setFreeItemMenu(data[0]);
196                 });
197                 sock.current.readBytes(INIT_SRAM + PYRAMID_SCREEN, 1, (data) => {
198                         setPyramidOpen(!!(data[0] & 0x20));
199                 });
200         }, [inGame, sock]);
201
202         const applicable = React.useMemo(() => {
203                 return !seedType &&
204                         gtCrystals &&
205                         GT_TYPES.includes(ganonType) &&
206                         !pyramidOpen &&
207                         !(freeItemMenu & 0x02);
208         }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
209
210         // update crystals information
211         React.useEffect(() => {
212                 if (!applicable || !inGame || hasBigKey) return;
213                 const updateCrystals = () => {
214                         sock.current.readWRAM(SAVE_WRAM + OWNED_CRYSTALS, 1, (data) => {
215                                 let owned = 0;
216                                 for (let i = 0; i < 7; ++i) {
217                                         if (data[0] & Math.pow(2, i)) {
218                                                 ++owned;
219                                         }
220                                 }
221                                 setOwnedCrystals(owned);
222                         });
223                 };
224                 // increase frequency for the last
225                 const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
226                 return () => {
227                         clearInterval(timer);
228                 };
229         }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
230
231         // start game once all required crystals have been acquired
232         React.useEffect(() => {
233                 if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
234                 controls.current.onStart();
235                 const updateDungeon = () => {
236                         sock.current.readWRAM(CURRENT_DUNGEON, 2, (data) => {
237                                 setLastEntrance(data[0] + (data[1] * 256));
238                         });
239                 };
240                 const timer = setInterval(updateDungeon, 1000);
241                 return () => {
242                         clearInterval(timer);
243                 };
244         }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
245
246         // stop game when GT has been entered
247         React.useEffect(() => {
248                 if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
249                 if (lastEntrance === GT_ENTRANCE_ID) {
250                         controls.current.onStop();
251                         setHasEntered(true);
252                 }
253         }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
254
255         // watch GT state
256         React.useEffect(() => {
257                 if (!applicable || !hasEntered || hasBigKey) return;
258                 const updateGTState = () => {
259                         const roomDataSize = ROOM_DATA_END - ROOM_DATA_START;
260                         sock.current.readWRAM(SAVE_WRAM + ROOM_DATA_START, roomDataSize, (data) => {
261                                 const gtState = getGTBasementState(data);
262                                 const gtCount = countGTBasementState(gtState);
263                                 setBasement(old => {
264                                         const cmp = compareGTBasementState(old.state, gtState);
265                                         if (cmp) {
266                                                 return {
267                                                         state: gtState,
268                                                         last: cmp,
269                                                         count: gtCount,
270                                                         torch: cmp === 'torchSeen' ? gtCount : old.torch,
271                                                 };
272                                         }
273                                         return old;
274                                 });
275                         });
276                 };
277                 const timer = setInterval(updateGTState, 500);
278                 return () => {
279                         clearInterval(timer);
280                 };
281         }, [applicable, hasBigKey, hasEntered]);
282
283         React.useEffect(() => {
284                 if (!applicable) return;
285                 if (hasBigKey) {
286                         const solution = basement.last === 'torch' ? basement.torch : basement.count;
287                         controls.current.onSolve(solution);
288                 } else {
289                         sock.current.readWRAM(SAVE_WRAM + BIG_KEYS_1, 1, (data) => {
290                                 setHasBigKey(!!(data[0] & 0x04));
291                         });
292                 }
293         }, [applicable, basement, controls, hasBigKey]);
294
295         const statusMsg = React.useMemo(() => {
296                 if (!enabled) {
297                         return 'disabled';
298                 }
299                 if (status.error) {
300                         return 'error';
301                 }
302                 if (!status.connected) {
303                         return 'disconnected';
304                 }
305                 if (!status.device) {
306                         return 'no-device';
307                 }
308                 if (!inGame) {
309                         return 'not-in-game';
310                 }
311                 if (!applicable) {
312                         return 'not-applicable';
313                 }
314                 return 'tracking';
315         }, [applicable, enabled, inGame, status]);
316
317         return <div>
318                 {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
319                         <Icon.WARNING
320                                 className="me-2 text-warning"
321                                 size="lg"
322                                 title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
323                         />
324                 : null}
325                 {['not-applicable', 'not-in-game'].includes(statusMsg) ?
326                         <Icon.INFO
327                                 className="me-2 text-info"
328                                 size="lg"
329                                 title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
330                         />
331                 : null}
332                 <ToggleSwitch
333                         onChange={toggle}
334                         title={t('autoTracking.heading')}
335                         value={enabled}
336                 />
337         </div>;
338 };
339
340 GuessingGameAutoTracking.propTypes = {
341         onSolve: PropTypes.func,
342         onStart: PropTypes.func,
343         onStop: PropTypes.func,
344 };
345
346 export default GuessingGameAutoTracking;