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