From: Daniel Karbach Date: Thu, 14 Mar 2024 17:17:01 +0000 (+0100) Subject: guessing game auto-tracking X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=73c29bf37f10df401d87d14cc26999f88ce77379;p=alttp.git guessing game auto-tracking --- diff --git a/resources/js/app/index.js b/resources/js/app/index.js index 2c80c79..385ed7d 100644 --- a/resources/js/app/index.js +++ b/resources/js/app/index.js @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import Routes from './Routes'; import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext'; +import { SNESProvider } from '../hooks/snes'; import { UserProvider } from '../hooks/user'; import i18n from '../i18n'; @@ -21,14 +22,16 @@ const App = () => { }, []); return - - - - {t('general.appName')} - - - - + + + + + {t('general.appName')} + + + + + ; }; diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 4d9559c..f3caeb3 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -74,6 +74,7 @@ Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy'); Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark'); Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark'); Icon.HASH = makePreset('HashIcon', 'hashtag'); +Icon.INFO = makePreset('Info', 'circle-info'); Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke'); Icon.LANGUAGE = makePreset('LanguageIcon', 'language'); Icon.LOCKED = makePreset('LockedIcon', 'lock'); diff --git a/resources/js/components/common/ToggleSwitch.js b/resources/js/components/common/ToggleSwitch.js index da9653c..db07619 100644 --- a/resources/js/components/common/ToggleSwitch.js +++ b/resources/js/components/common/ToggleSwitch.js @@ -12,6 +12,7 @@ const ToggleSwitch = ({ onChange, onLabel, readonly, + title, value, }) => { const toggle = () => { @@ -43,6 +44,7 @@ const ToggleSwitch = ({ role="button" aria-pressed={value} tabIndex="0" + title={title} onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null} onClick={handleClick} onKeyDown={handleKey} @@ -68,6 +70,7 @@ ToggleSwitch.propTypes = { onChange: PropTypes.func, onLabel: PropTypes.string, readonly: PropTypes.bool, + title: PropTypes.string, value: PropTypes.bool, }; @@ -81,6 +84,7 @@ ToggleSwitch.defaultProps = { onChange: null, onLabel: '', readonly: false, + title: null, value: false, }; diff --git a/resources/js/components/twitch-bot/GuessingGameAutoTracking.js b/resources/js/components/twitch-bot/GuessingGameAutoTracking.js new file mode 100644 index 0000000..fae142c --- /dev/null +++ b/resources/js/components/twitch-bot/GuessingGameAutoTracking.js @@ -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
+ {['disconnected', 'error', 'no-device'].includes(statusMsg) ? + + : null} + {['not-applicable', 'not-in-game'].includes(statusMsg) ? + + : null} + +
; +}; + +GuessingGameAutoTracking.propTypes = { + onSolve: PropTypes.func, + onStart: PropTypes.func, + onStop: PropTypes.func, +}; + +export default GuessingGameAutoTracking; diff --git a/resources/js/components/twitch-bot/GuessingGameControls.js b/resources/js/components/twitch-bot/GuessingGameControls.js index c3846c5..50a3c3a 100644 --- a/resources/js/components/twitch-bot/GuessingGameControls.js +++ b/resources/js/components/twitch-bot/GuessingGameControls.js @@ -3,6 +3,7 @@ import React from 'react'; import { Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import GuessingGameAutoTracking from './GuessingGameAutoTracking'; import { hasActiveGuessing, isAcceptingGuesses, @@ -23,29 +24,36 @@ const GuessingGameControls = ({ ]; return
-
- - - +
+
+ + + +
+
{hasActiveGuessing(channel) ?
diff --git a/resources/js/helpers/SNESSocket.js b/resources/js/helpers/SNESSocket.js new file mode 100644 index 0000000..27441e8 --- /dev/null +++ b/resources/js/helpers/SNESSocket.js @@ -0,0 +1,107 @@ +export default class SNESSocket { + + constructor(uri) { + this.uri = uri; + this.queue = []; + this.device = ''; + this.deviceList = []; + this.open(); + } + + open() { + if (this.sock) { + this.sock.close(); + this.sock = null; + } + this.sock = new WebSocket(this.uri); + this.sock.binaryType = 'arraybuffer'; + this.sock.onclose = () => { + if (this.onclose) { + this.onclose(); + } + }; + this.sock.onerror = (e) => { + if (this.onerror) { + this.onerror(e); + } + }; + this.sock.onopen = () => { + if (this.onopen) { + this.onopen(); + } + }; + this.sock.onmessage = (e) => { + if (this.queue.length) { + const handler = this.queue.shift(); + handler(e.data); + } + }; + } + + close() { + this.sock.close(); + } + + isOpen() { + return this.sock.readyState === 1; + } + + send(opcode, flags, operands, callback) { + const payload = { + Opcode: opcode, + Space: 'SNES', + }; + if (flags) { + payload.Flags = flags; + } + if (operands) { + payload.Operands = operands; + } + this.sock.send(JSON.stringify(payload)); + if (callback) { + this.queue.push(callback); + } + } + + attachDevice(device) { + this.device = device; + this.send('Attach', null, [device]); + } + + readBytes(start, size, callback) { + const handler = (rsp) => { + if (callback) callback(new Uint8Array(rsp)); + }; + this.send('GetAddress', null, [start.toString(16), size.toString(16)], handler); + } + + readSRAM(start, size, callback) { + this.readBytes(start + 0xE00000, size, callback); + } + + readVRAM(start, size, callback) { + this.readBytes(start + 0xF70000, size, callback); + } + + readWRAM(start, size, callback) { + this.readBytes(start + 0xF50000, size, callback); + } + + requestDeviceInfo(callback) { + const handler = (rsp) => { + const data = JSON.parse(rsp); + if (callback) callback(data); + }; + this.send('Info', null, null, handler); + } + + requestDeviceList(callback) { + const handler = (rsp) => { + const data = JSON.parse(rsp); + this.deviceList = data.Results || []; + if (callback) callback(data); + }; + this.send('DeviceList', null, null, handler); + } + +} diff --git a/resources/js/helpers/alttp-ram.js b/resources/js/helpers/alttp-ram.js new file mode 100644 index 0000000..2ae71cf --- /dev/null +++ b/resources/js/helpers/alttp-ram.js @@ -0,0 +1,100 @@ +export const isChestOpen = (data, room, chest) => { + if (chest < 4) { + return !!(data && (data[2 * room] & Math.pow(2, chest + 4))); + } else { + return !!(data && (data[(2 * room) + 1] & Math.pow(2, chest - 4))); + } +}; + +export const hasVisitedQuadrant = (data, room, quadrant) => { + return !!(data && (data[2 * room] & Math.pow(2, quadrant - 1))); +}; + +export const GT_BASEMENT_CHECKS = [ + 'iceM', + 'iceL', + 'iceR', + 'dmUL', + 'dmUR', + 'dmBL', + 'dmBR', + 'randoUL', + 'randoUR', + 'randoBL', + 'randoBR', + 'fireSnake', + 'mapChest', + 'hopeL', + 'hopeR', + 'bobsChest', + 'torchSeen', + 'tileRoom', + 'compassUL', + 'compassUR', + 'compassBL', + 'compassBR', +]; + +export const GT_BASEMENT_ALL = [ + 'iceM', + 'iceL', + 'iceR', + 'dmUL', + 'dmUR', + 'dmBL', + 'dmBR', + 'randoUL', + 'randoUR', + 'randoBL', + 'randoBR', + 'fireSnake', + 'mapChest', + 'hopeL', + 'hopeR', + 'bobsChest', + 'torch', + 'torchSeen', + 'tileRoom', + 'compassUL', + 'compassUR', + 'compassBL', + 'compassBR', +]; + +export const getGTBasementState = (data) => ({ + iceM: isChestOpen(data, 0x1C, 0), + iceL: isChestOpen(data, 0x1C, 1), + iceR: isChestOpen(data, 0x1C, 2), + dmUL: isChestOpen(data, 0x7B, 0), + dmUR: isChestOpen(data, 0x7B, 1), + dmBL: isChestOpen(data, 0x7B, 2), + dmBR: isChestOpen(data, 0x7B, 3), + randoUL: isChestOpen(data, 0x7C, 0), + randoUR: isChestOpen(data, 0x7C, 1), + randoBL: isChestOpen(data, 0x7C, 2), + randoBR: isChestOpen(data, 0x7C, 3), + fireSnake: isChestOpen(data, 0x7D, 0), + mapChest: isChestOpen(data, 0x8B, 0), + hopeL: isChestOpen(data, 0x8C, 1), + hopeR: isChestOpen(data, 0x8C, 2), + bobsChest: isChestOpen(data, 0x8C, 3), + torchSeen: hasVisitedQuadrant(data, 0x8C, 4), + torch: isChestOpen(data, 0x8C, 6), + tileRoom: isChestOpen(data, 0x8D, 0), + compassUL: isChestOpen(data, 0x9D, 0), + compassUR: isChestOpen(data, 0x9D, 1), + compassBL: isChestOpen(data, 0x9D, 2), + compassBR: isChestOpen(data, 0x9D, 3), +}); + +export const countGTBasementState = (state) => + GT_BASEMENT_CHECKS.reduce((acc, cur) => state[cur] ? acc + 1 : acc, 0); + +export const compareGTBasementState = (prev, cur) => { + for (let i = 0; i < GT_BASEMENT_ALL.length; ++i) { + if (prev[GT_BASEMENT_ALL[i]] !== cur[GT_BASEMENT_ALL[i]]) { + return GT_BASEMENT_ALL[i]; + } + } + return ''; +}; diff --git a/resources/js/hooks/snes.js b/resources/js/hooks/snes.js new file mode 100644 index 0000000..05e84b2 --- /dev/null +++ b/resources/js/hooks/snes.js @@ -0,0 +1,125 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import SNESSocket from '../helpers/SNESSocket'; + +const context = React.createContext({}); + +export const useSNES = () => React.useContext(context); + +export const SNESProvider = ({ children }) => { + const [enabled, setEnabled] = React.useState(false); + + const sock = React.useRef(null); + + const [settings, setSettings] = React.useState({ + proto: 'ws', + host: 'localhost', + port: 8080, + device: '', + }); + + const [status, setStatus] = React.useState({ + connected: false, + device: '', + deviceList: [], + error: false, + }); + + React.useEffect(() => { + if (sock.current) { + sock.current.close(); + sock.current = null; + } + if (enabled) { + const tryAttach = () => { + const { deviceList } = sock.current; + let device = ''; + if (deviceList.includes(settings.device)) { + device = settings.device; + } else if (deviceList.length > 0) { + device = deviceList[0]; + } + setStatus(s => ({ ...s, device, deviceList })); + if (device) { + sock.current.attachDevice(device); + } + }; + sock.current = new SNESSocket(`${settings.proto}://${settings.host}:${settings.port}`); + sock.current.onclose = () => { + setStatus({ + connected: false, + device: '', + deviceList: [], + error: false, + }); + }; + sock.current.onerror = (e) => { + setStatus({ + connected: false, + device: '', + deviceList: [], + error: e, + }); + }; + sock.current.onopen = () => { + setStatus({ + connected: true, + device: '', + deviceList: [], + error: false, + }); + sock.current.requestDeviceList(() => { + tryAttach(); + }); + }; + const watchdog = setInterval(() => { + if (!sock.current.isOpen()) { + sock.current.open(); + return; + } + if (!sock.current.device) { + sock.current.requestDeviceList(() => { + tryAttach(); + }); + } + }, 5000); + return () => { + clearInterval(watchdog); + }; + } + }, [enabled, settings]); + + const enable = React.useCallback(() => { + setEnabled(prevEnabled => { + if (prevEnabled) return true; + return true; + }); + }, []); + + const disable = React.useCallback(() => { + setEnabled(prevEnabled => { + if (!prevEnabled) return false; + return false; + }); + }, []); + + React.useEffect(() => { + const savedSettings = localStorage.getItem('snes.settings'); + if (savedSettings) { + setSettings(JSON.parse(savedSettings)); + } + }, []); + + const value = React.useMemo(() => { + return { disable, enable, enabled, settings, sock, status }; + }, [disable, enable, enabled, settings, sock, status]); + + return + {children} + ; +}; + +SNESProvider.propTypes = { + children: PropTypes.node, +}; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index a2ac1d1..3cac15d 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -52,6 +52,18 @@ export default { rejectSuccess: 'Abgelehnt', rejectError: 'Fehler beim Ablehnen', }, + autoTracking: { + heading: 'Auto-Tracking', + statusMsg: { + disabled: 'Deaktiviert', + disconnected: 'Verbindung getrennt', + error: 'Verbindungsfehler', + 'no-device': 'Kein SNES', + 'not-applicable': 'Verbunden mit {{ device }}, ungeeigneter Modus', + 'not-in-game': 'Verbunden mit {{ device }}, nicht im Spiel', + tracking: 'Verbunden mit {{ device }}', + }, + }, button: { add: 'Hinzufügen', back: 'Zurück', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 5ad5c35..d9cd50f 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -52,6 +52,18 @@ export default { rejectSuccess: 'Rejected', rejectError: 'Error rejecting', }, + autoTracking: { + heading: 'Auto tracking', + statusMsg: { + disabled: 'Disabled', + disconnected: 'Disconnected', + error: 'Connection error', + 'no-device': 'No device', + 'not-applicable': 'Connected to {{ device }}, mode not applicable', + 'not-in-game': 'Connected to {{ device }}, not in game', + tracking: 'Connected to {{ device }}', + }, + }, button: { add: 'Add', back: 'Back', diff --git a/resources/sass/form.scss b/resources/sass/form.scss index 2202be6..411188f 100644 --- a/resources/sass/form.scss +++ b/resources/sass/form.scss @@ -67,7 +67,6 @@ label { .handle-label { display: inline-block; - margin-top: 2px; font-size: 18px; font-weight: 600; white-space: nowrap;