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