import base64 from dataclasses import dataclass from enum import Enum import json import logging from pprint import pprint import os import queue import struct import threading import time from typing import Callable import hid import requests import urllib3 # Local HTTPS connection, we need to not verify the cert urllib3.disable_warnings() # Disable so we can debug without a log for every request logging.getLogger('urllib3.connectionpool').disabled = True log = logging.getLogger('valconomy') @dataclass class ValorantPlayerInfo: is_idle: bool = False is_party_owner: bool = True max_party_size: int = 5 party_size: int = 1 party_state: str = None queue_type: str = None game_state: str = None map: str = None team: str = None score: int = 0 enemy_score: int = 0 @dataclass class RiotPlayerInfo: uuid: str name: str tag: str state: str valorant: ValorantPlayerInfo = None @classmethod def dummy(cls, **kwargs): return cls('00000000-0000-0000-0000-000000000000', 'Player', 'gamer', 'dnd', **kwargs) def full_name(self) -> str: return f'{self.name}#{self.tag}' class ValorantLocalClient: poll_interval = 0.5 def __init__(self, callback: Callable[[RiotPlayerInfo, bool], None]): self.callback = callback self.lockfile_path = os.path.join(os.environ['LOCALAPPDATA'], r'Riot Games\Riot Client\Config\lockfile') self.port, self.password = None, None self.running = True self.players = {} def _load_credentials(self): if not os.path.exists(self.lockfile_path): return False try: with open(self.lockfile_path) as f: for line in f: if line.startswith('Riot Client'): client, pid, self.port, self.password, proto = line.rstrip().split(':') assert proto == 'https' return True except FileNotFoundError: return False return False def _do_poll(self): try: resp = requests.get( f'https://127.0.0.1:{self.port}/chat/v4/presences', auth=('riot', self.password), verify=False) resp.raise_for_status() data = resp.json() except (requests.HTTPError, requests.ConnectionError) as ex: # Most likely one of these means the Riot Client shut down log.error(f'Failed to make request to Riot Client: {ex}') return False next_players = {} for p in data['presences']: if p['product'] != 'valorant': continue v = json.loads(base64.b64decode(p.pop('private'))) # Valorant-specfic # pprint(p) # pprint(v) val_info = ValorantPlayerInfo( is_idle=v['isIdle'], is_party_owner=v['isPartyOwner'], max_party_size=v['maxPartySize'], party_size=v['partySize'], party_state=v['partyState'], queue_type=v['queueId'] or None, game_state=v['sessionLoopState'], map=v['matchMap'] or None, score=v['partyOwnerMatchScoreAllyTeam'], enemy_score=v['partyOwnerMatchScoreEnemyTeam']) match v['partyOwnerMatchCurrentTeam']: case 'Red': val_info.team = 'attackers' case 'Blue': val_info.team = 'defenders' info = RiotPlayerInfo(p['puuid'], p['game_name'], p['game_tag'], p['state'], valorant=val_info) last = self.players.get(info.uuid) if info != last: self.callback(info, False) next_players[info.uuid] = info for uuid in self.players.keys() - next_players.keys(): self.callback(self.players[uuid], True) self.players = next_players return True def run(self): while self.running: if self.password is None: if not self._load_credentials(): time.sleep(self.poll_interval) continue log.info('Detected Riot Client credentials, starting polling') if not self._do_poll(): for p in self.players.values(): self.callback(p, True) self.players = {} self.port, self.password = None, None time.sleep(self.poll_interval) def stop(self): self.running = False class EconomyDecision(Enum): BUY = 0 SAVE = 1 BONUS = 2 MATCH_TEAM = 3 class ValconomyHandler: # Valorant isn't open def none(self): log.info('Val Time soon?') # Welcome the user (back) to the game! def menu(self, info: RiotPlayerInfo, was_idle: bool=False): if was_idle: log.info(f"Welcome back, {info.name}!") else: log.info(f"It's Val Time, {info.name}!") # The user is idle in the menu... def idle(self, info: RiotPlayerInfo): log.info(f'Come back soon, {info.name}...') # Just entered queue def queue_start(self, info: RiotPlayerInfo): if info.valorant.is_party_owner and info.valorant.queue_type == 'unrated': log.info('Uhhhh should that be comp, MoonStar?') else: log.info(f'Hope you find a game quickly, {info.name}!') def match_found(self, info: RiotPlayerInfo): if info.valorant.queue_type == 'premier-seasonmatch': log.info('Do the Cosmonauts proud!') else: log.info("I hope it's not Split...") # Loaded into the agent select def pregame(self, info: RiotPlayerInfo): if info.valorant.map == '/Game/Maps/Bonsai/Bonsai': log.info('Ewwwww, Split....') else: log.info('Pick a good agent!') # Game where we're not providing economy help def game_generic(self, info: RiotPlayerInfo): match info.valorant.queue_type: case 'hurm': log.info('Have a good TDM!') case 'deathmatch': log.info('Have a good deathmatch!') case 'ggteam': log.info('Have a good Escalation!') case 'spikerush': log.info('Have a good Spike Rush!') case _: if info.valorant.max_party_size == 12: log.info('Have a good custom!') else: log.info('Have a good game!') # Loaded into the game def game_start(self, info: RiotPlayerInfo): log.info('OK best of luck!') # Round started def round_start(self, info: RiotPlayerInfo, won: bool, economy: EconomyDecision): msg = f'Start round {info.valorant.score}-{info.valorant.enemy_score}' if won is not None: msg += f' ({"won" if won else "lost"})' log.info(msg) match economy: case EconomyDecision.BUY: log.info('You should buy!') case EconomyDecision.SAVE: log.info('Time to save...') case EconomyDecision.BONUS: log.info('Keep your gun from last round!') case EconomyDecision.MATCH_TEAM: log.info('Follow the team economy!') def game_over(self, info: RiotPlayerInfo, won: bool): if won: log.info('Well played!') else: log.info('Hard luck...') class HIDValconomyHandler(ValconomyHandler): vid = 0x6969 pid = 0x0004 def __init__(self): self.dev = None self.running = True self.queue = queue.Queue(128) self._thread = None def close(self): if self.dev is not None: self.dev.close() def _dev_ready(self): try: if self.dev is None: dev = hid.device() dev.open(vendor_id=self.vid, product_id=self.pid) self.dev = dev log.info(f'USB device opened') # 2 bytes: report ID and returned value # We get back the same report ID and value # Set to report size + 1 to satisfy W*ndoze data = self.dev.get_input_report(0, 65) assert len(data) == 2 return data[1] == 1 except OSError as ex: if self.dev is not None: log.warning(f'USB device lost') self.dev = None return False def _do(self, cmd, *vals, fmt=None): if fmt is None: fmt = '' fmt = ' {s}') if s == GameState.IN_GAME: if p.valorant.score == self.score and p.valorant.enemy_score == self.score_enemy: # Same score line return won_last = None if self.score != -1: won_last = True if p.valorant.score > self.score else False self.round_history.append(won_last) over = False if p.valorant.queue_type == 'swiftplay': if p.valorant.score == 5 or p.valorant.enemy_score == 5: over = True eco = EconomyDecision.BUY else: if p.valorant.score > 12 or p.valorant.enemy_score > 12: if p.valorant.queue_type == 'unrated' or abs(p.valorant.score - p.valorant.enemy_score) >= 2: over = True eco = EconomyDecision.MATCH_TEAM rounds_played = p.valorant.score + p.valorant.enemy_score if p.valorant.enemy_score == 12: # Enemy about to win! eco = EconomyDecision.BUY elif rounds_played in (0, 12): # First round (of half) eco = EconomyDecision.BUY elif rounds_played in (1, 13): # Second round (of half) eco = EconomyDecision.BUY if won_last else EconomyDecision.SAVE elif rounds_played in (2, 14): # Third round (of half) match self.round_history[-2:]: case [True, True]: eco = EconomyDecision.BONUS case [True, False]: eco = EconomyDecision.SAVE case [False, _]: eco = EconomyDecision.BUY elif rounds_played >= 24: # Sudden death or overtime (buy either way) eco = EconomyDecision.BUY elif rounds_played == 11: # Last round of half eco = EconomyDecision.BUY if not over: self.handler.round_start(p, won_last, eco) self.score = p.valorant.score self.score_enemy = p.valorant.enemy_score self.last = GameState.IN_GAME return if s == self.last: return if s != GameState.GAME_OVER: self.score = self.score_enemy = -1 self.round_history = [] match s: case GameState.IN_GAME_GENERIC: self.handler.game_generic(p) case GameState.GAME_START: self.handler.game_start(p) case GameState.PRE_GAME: self.handler.pregame(p) case GameState.GAME_FOUND: self.handler.match_found(p) case GameState.IN_QUEUE: self.handler.queue_start(p) case GameState.IDLE: self.handler.idle(p) case GameState.MENU: self.handler.menu(p, self.last == GameState.IDLE) case GameState.NONE: self.handler.none() case GameState.GAME_OVER: self.handler.game_over(p, self.score > self.score_enemy) self.last = s def handle_presence(self, p: RiotPlayerInfo, gone: bool): log.debug(f'{repr(p)}, gone: {gone}') if p.uuid != self.uuid: return if gone: self.handle_state(GameState.NONE, p) return if p.valorant.party_state == 'MATCHMADE_GAME_STARTING': self.handle_state(GameState.GAME_FOUND, p) return if p.valorant.party_state == 'MATCHMAKING': self.handle_state(GameState.IN_QUEUE, p) return if p.valorant.queue_type in ('unrated', 'competitive', 'premier-seasonmatch', 'swiftplay'): if p.valorant.game_state == 'PREGAME': self.handle_state(GameState.PRE_GAME, p) return if p.valorant.game_state == 'INGAME': if self.score == -1 and p.valorant.score == p.valorant.enemy_score == 0: self.handle_state(GameState.GAME_START, p) if (self.score > 0 or self.score_enemy > 0) and p.valorant.score == p.valorant.enemy_score == 0: self.handle_state(GameState.GAME_OVER, p) else: self.handle_state(GameState.IN_GAME, p) return if p.valorant.game_state in ('PREGAME', 'INGAME'): self.handle_state(GameState.IN_GAME_GENERIC, p) return if p.valorant.game_state == 'MENUS': if p.valorant.is_idle: self.handle_state(GameState.IDLE, p) return self.handle_state(GameState.MENU, p)