class MapsRecorder: def __init__(self, rcon: RecordedRcon): self.rcon = rcon self.red = redis.Redis(connection_pool=get_redis_pool()) self.maps_history = MapsHistory() self.prev_map = None self._restore_state() def _restore_state(self): current_map = self.rcon.get_map() try: last = self.maps_history[0] except IndexError: logger.warning("Map history is empty, can't restore state") return started_time = datetime.fromtimestamp(last.get("start")) elapsed_time = datetime.now() - started_time if last.get("name") == current_map and elapsed_time < timedelta( hours=2): logging.info("State recovered successfully") self.prev_map = current_map else: logging.warning( "The map recorder was offline for too long, the maps history will have gaps" ) def detect_map_change(self): try: current_map = self.rcon.get_map() except CommandFailedError: logger.info("Faied to get current map. Skipping") return logger.debug("Checking for map change current: %s prev: %s", current_map, self.prev_map) if self.prev_map != current_map: if self.prev_map in ALL_MAPS: self.maps_history.save_map_end(self.prev_map) if current_map in ALL_MAPS: self.maps_history.save_new_map(current_map) logger.info( "Map change detected updating state. Prev map %s New Map %s", self.prev_map, current_map, ) if not os.getenv('SILENT_MAP_RECORDER', None): send_to_discord_audit( f"map change detected {dict_to_discord(dict(previous=self.prev_map, new=current_map))}", by="MAP_RECORDER", silent=False) self.prev_map = current_map self.last_map_change_time = datetime.now() return True return False
def public_info(request): status = ctl.get_status() try: current_map = MapsHistory()[0] except IndexError: logger.error( "Can't get current map time, map_recorder is probably offline") current_map = {"name": status["map"], "start": None, "end": None} current_map = dict( just_name=map_name(current_map["name"]), human_name=LONG_HUMAN_MAP_NAMES.get(current_map["name"], current_map["name"]), **current_map, ) vote_status = get_votes_status(none_on_fail=True) next_map = ctl.get_next_map() return api_response( result=dict( current_map=current_map, **status, vote_status=vote_status, next_map=next_map, ), failed=False, command="public_info", )
def pick_default_next_map(self): selection = self.get_selection() maps_history = MapsHistory() config = VoteMapConfig() all_maps = ALL_MAPS if not config.get_votemap_allow_default_to_offsensive(): logger.debug( "Not allowing default to offensive, removing all offensive maps" ) selection = [m for m in selection if not "offensive" in m] all_maps = [m for m in ALL_MAPS if not "offensive" in m] if not maps_history: raise ValueError("Map history is empty") return { DefaultMethods.least_played_suggestions: partial(self.pick_least_played_map, selection), DefaultMethods.least_played_all_maps: partial(self.pick_least_played_map, all_maps), DefaultMethods.random_all_maps: lambda: random.choice( list(set(all_maps) - set([maps_history[0]["name"]]))), DefaultMethods.random_suggestions: lambda: random.choice( list(set(selection) - set([maps_history[0]["name"]]))), }[config.get_votemap_default_method()]()
def gen_selection(self): config = VoteMapConfig() logger.debug( f"""Generating new map selection for vote map with the following criteria: {ALL_MAPS} {config.get_votemap_number_of_options()=} {config.get_votemap_ratio_of_offensives_to_offer()=} {config.get_votemap_number_of_last_played_map_to_exclude()=} {config.get_votemap_consider_offensive_as_same_map()=} {config.get_votemap_allow_consecutive_offensives()=} {config.get_votemap_allow_consecutive_offensives_of_opposite_side()=} {config.get_votemap_default_method()=} """) selection = suggest_next_maps( MapsHistory(), ALL_MAPS, selection_size=config.get_votemap_number_of_options(), exclude_last_n=config. get_votemap_number_of_last_played_map_to_exclude(), offsensive_ratio=config.get_votemap_ratio_of_offensives_to_offer(), consider_offensive_as_same_map=config. get_votemap_consider_offensive_as_same_map(), allow_consecutive_offensive=config. get_votemap_allow_consecutive_offensives(), allow_consecutive_offensives_of_opposite_side=config. get_votemap_allow_consecutive_offensives_of_opposite_side(), current_map=self.get_current_map(), ) self.red.delete("MAP_SELECTION") self.red.lpush("MAP_SELECTION", *selection) logger.info("Saved new selection: %s", selection)
def register_vote(self, player_name, vote_timestamp, vote_content): try: current_map = MapsHistory()[0] min_time = current_map["start"] except IndexError: logger.error("Map history is empty - Can't register vote") raise except KeyError: logger.error("Map history is corrupted - Can't register vote") raise if vote_timestamp < min_time: logger.warning( f"Vote is too old {player_name=}, {vote_timestamp=}, {vote_content=}, {current_map=}" ) selection = self.get_selection() try: vote_idx = int(vote_content) selected_map = selection[vote_idx] self.red.hset("VOTES", player_name, selected_map) except (TypeError, ValueError, IndexError): raise InvalidVoteError( f"Vote must be a number between 0 and {len(selection) - 1}") logger.info( f"Registered vote from {player_name=} for {selected_map=} - {vote_content=}" ) return selected_map
def current_game_stats(): try: current_map = MapsHistory()[0] except IndexError: logger.error("No maps information available") return {} return TimeWindowStats().get_players_stats_from_time(current_map["start"])
def pick_least_played_map(self, maps): maps_history = MapsHistory() if not maps: raise ValueError("Can't pick a default. No maps to pick from") history = [obj["name"] for obj in maps_history] index = 0 for name in maps: try: idx = history.index(name) except ValueError: return name index = max(idx, index) return history[index]
def get_map_history(request): data = _get_data(request) res = MapsHistory()[:] if data.get("pretty"): res = [ dict( name=i["name"], start=datetime.datetime.fromtimestamp(i["start"]).isoformat() if i["start"] else None, end=datetime.datetime.fromtimestamp(i["end"]).isoformat() if i["end"] else None, ) for i in res ] return api_response(result=res, command="get_map_history", arguments={}, failed=False)
def on_map_change(old_map: str, new_map: str): logger.info("Running on_map_change hooks with %s %s", old_map, new_map) try: config = VoteMapConfig() if config.get_vote_enabled(): votemap = VoteMap() votemap.gen_selection() votemap.clear_votes() votemap.apply_with_retry(nb_retry=4) #temporary_welcome_in( # "%s{votenextmap_vertical}" % config.get_votemap_instruction_text(), # seconds=60 * 20, # restore_after_seconds=60 * 5, #) except Exception: logger.exception("Unexpected error while running vote map") try: record_stats_worker(MapsHistory()[1]) except Exception: logger.exception("Unexpected error while running stats worker")
#!/usr/bin/env python from sqlalchemy.orm import with_expression from rcon.models import enter_session from rcon.workers import _record_stats from rcon.utils import MapsHistory import sys with enter_session() as sess: if not sys.argv[-1] == 'skiperase': sess.execute('truncate table map_history cascade') sess.commit() for m in MapsHistory(): try: if m['start'] and m['end']: _record_stats(m) except Exception as e: print(f"Unable to process stats for {m}: {repr(2)}")
def __init__(self, rcon: RecordedRcon): self.rcon = rcon self.red = redis.Redis(connection_pool=get_redis_pool()) self.maps_history = MapsHistory() self.prev_map = None self._restore_state()
class VoteMap: def __init__(self) -> None: self.red = get_redis_client() def is_vote(self, message): match = re.match("(\d)", message) if not match: match = re.match("!votemap\s*(.*)", message) if not match: return False if not match.groups(): raise InvalidVoteError("You must specify the number of the map") return match.groups()[0] def register_vote(self, player_name, vote_timestamp, vote_content): try: current_map = MapsHistory()[0] min_time = current_map["start"] except IndexError: logger.error("Map history is empty - Can't register vote") raise except KeyError: logger.error("Map history is corrupted - Can't register vote") raise if vote_timestamp < min_time: logger.warning( f"Vote is too old {player_name=}, {vote_timestamp=}, {vote_content=}, {current_map=}" ) selection = self.get_selection() try: vote_idx = int(vote_content) selected_map = selection[vote_idx] self.red.hset("VOTES", player_name, selected_map) except (TypeError, ValueError, IndexError): raise InvalidVoteError( f"Vote must be a number between 0 and {len(selection) - 1}") logger.info( f"Registered vote from {player_name=} for {selected_map=} - {vote_content=}" ) return selected_map def get_vote_overview(self): try: votes = self.get_votes() maps = Counter(votes.values()).most_common() return {"total_votes": len(votes), "winning_maps": maps} except Exception: logger.exception("Can't produce vote overview") def clear_votes(self): self.red.delete("VOTES") def get_votes(self): votes = self.red.hgetall("VOTES") or {} return {k.decode(): v.decode() for k, v in votes.items()} def get_current_map(self): map_ = RecordedRcon(SERVER_INFO).get_map() if map_.endswith("_RESTART"): map_ = map_.replace("_RESTART", "") if map_ not in ALL_MAPS: raise ValueError("Invalid current map %s map_") return map_ def gen_selection(self): config = VoteMapConfig() logger.debug( f"""Generating new map selection for vote map with the following criteria: {ALL_MAPS} {config.get_votemap_number_of_options()=} {config.get_votemap_ratio_of_offensives_to_offer()=} {config.get_votemap_number_of_last_played_map_to_exclude()=} {config.get_votemap_consider_offensive_as_same_map()=} {config.get_votemap_allow_consecutive_offensives()=} {config.get_votemap_allow_consecutive_offensives_of_opposite_side()=} {config.get_votemap_default_method()=} """) selection = suggest_next_maps( MapsHistory(), ALL_MAPS, selection_size=config.get_votemap_number_of_options(), exclude_last_n=config. get_votemap_number_of_last_played_map_to_exclude(), offsensive_ratio=config.get_votemap_ratio_of_offensives_to_offer(), consider_offensive_as_same_map=config. get_votemap_consider_offensive_as_same_map(), allow_consecutive_offensive=config. get_votemap_allow_consecutive_offensives(), allow_consecutive_offensives_of_opposite_side=config. get_votemap_allow_consecutive_offensives_of_opposite_side(), current_map=self.get_current_map(), ) self.red.delete("MAP_SELECTION") self.red.lpush("MAP_SELECTION", *selection) logger.info("Saved new selection: %s", selection) def get_selection(self): return [v.decode() for v in self.red.lrange("MAP_SELECTION", 0, -1)] def pick_least_played_map(self, maps): maps_history = MapsHistory() if not maps: raise ValueError("Can't pick a default. No maps to pick from") history = [obj["name"] for obj in maps_history] index = 0 for name in maps: try: idx = history.index(name) except ValueError: return name index = max(idx, index) return history[index] def pick_default_next_map(self): selection = self.get_selection() maps_history = MapsHistory() config = VoteMapConfig() all_maps = ALL_MAPS if not config.get_votemap_allow_default_to_offsensive(): logger.debug( "Not allowing default to offensive, removing all offensive maps" ) selection = [m for m in selection if not "offensive" in m] all_maps = [m for m in ALL_MAPS if not "offensive" in m] if not maps_history: raise ValueError("Map history is empty") return { DefaultMethods.least_played_suggestions: partial(self.pick_least_played_map, selection), DefaultMethods.least_played_all_maps: partial(self.pick_least_played_map, all_maps), DefaultMethods.random_all_maps: lambda: random.choice( list(set(all_maps) - set([maps_history[0]["name"]]))), DefaultMethods.random_suggestions: lambda: random.choice( list(set(selection) - set([maps_history[0]["name"]]))), }[config.get_votemap_default_method()]() def apply_results(self): config = VoteMapConfig() votes = self.get_votes() first = Counter(votes.values()).most_common(1) if not first: next_map = self.pick_default_next_map() logger.warning( "No votes recorded, defaulting with %s using default winning map %s", config.get_votemap_default_method(), next_map, ) else: logger.info(f"{votes=}") next_map = first[0][0] if next_map not in ALL_MAPS: raise ValueError( f"{next_map=} is not part of the all map list {ALL_MAPS=}") if next_map not in (selection := self.get_selection()): raise ValueError( f"{next_map=} is not part of vote select {selection=}") logger.info(f"Winning map {next_map=}") # Sanity checks below rcon = RecordedRcon(SERVER_INFO) current_map = rcon.get_map() if current_map.replace("_RESTART", "") not in ALL_MAPS: raise ValueError( f"{current_map=} is not part of the all map list {ALL_MAPS=}") try: history_current_map = MapsHistory()[0]["name"] except (IndexError, KeyError): raise ValueError("History is empty") if current_map != history_current_map: raise ValueError( f"{current_map=} does not match history map {history_current_map=}" ) # Apply rotation safely current_rotation = rcon.get_map_rotation() try: current_map_idx = current_rotation.index( current_map.replace("_RESTART", "")) except ValueError: logger.warning( f"{current_map=} is not in {current_rotation=} will try to add" ) rcon.do_add_map_to_rotation(current_map.replace("_RESTART", "")) current_rotation = rcon.get_map_rotation() try: current_map_idx = current_rotation.index( current_map.replace("_RESTART", "")) except ValueError as e: raise ValueError( f"{current_map=} is not in {current_rotation=} addint it failed" ) from e try: next_map_idx = current_rotation.index(next_map) except ValueError: logger.info( f"{next_map=} no in rotation, adding it {current_rotation=}") rcon.do_add_map_to_rotation(next_map) else: if next_map_idx < current_map_idx: logger.info( f"{next_map=} is before {current_map=} removing and re-adding" ) rcon.do_remove_map_from_rotation(next_map) rcon.do_add_map_to_rotation(next_map) current_rotation = rcon.get_map_rotation() try: next_map_idx = current_rotation.index(next_map) if next_map_idx < current_map_idx: raise ValueError(f"{next_map=} is still before {current_map=}") except ValueError as e: raise ValueError( f"{next_map=} failed to be added to {current_rotation=}") maps_to_remove = current_rotation[current_map_idx + 1:next_map_idx] logger.debug(f"{current_map=} {current_rotation=} - {maps_to_remove=}") for map in maps_to_remove: # Remove the maps that are in between the current map and the desired next map rcon.do_remove_map_from_rotation(map) for map in maps_to_remove: rcon.do_add_map_to_rotation(map) # Check that it worked current_rotation = rcon.get_map_rotation() if (not current_rotation[current_map_idx] == current_map.replace( '_RESTART', '') and current_rotation[current_map_idx + 1] == next_map): raise ValueError( f"Applying the winning map {next_map=} after the {current_map=} failed: {current_rotation=}" ) logger.info( f"Successfully applied winning mapp {next_map=}, new rotation {current_rotation=}" ) return True