From 29aebf0e0d944dcdee9e0b3341eba747885ef5ae Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 15:31:07 +0200 Subject: [PATCH 01/12] Add stub for event table --- event_table.csv | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 event_table.csv diff --git a/event_table.csv b/event_table.csv new file mode 100644 index 0000000..afbd81f --- /dev/null +++ b/event_table.csv @@ -0,0 +1,2 @@ +name,type,majority_mod,cycle,start_delay,hammer,cuorum,uses +Calabozo,jail,1,24,0,0,1,-1 \ No newline at end of file From 0905907c878c9c6bba60ab3fb530d5d4d282ff5b Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 16:15:55 +0200 Subject: [PATCH 02/12] Add stub for alt.vcount mssages --- user.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/user.py b/user.py index e79b592..e141f2d 100644 --- a/user.py +++ b/user.py @@ -165,15 +165,15 @@ def post(self, message:str): Returns: [Robobrowser]: The resolved form. """ - self.browser.open(f'http://www.mediavida.com/foro/post.php?tid={self.config.thread_id}') - self._post = self.browser.get_form(id='postear') - self._post['cuerpo'].value = message + #self.browser.open(f'http://www.mediavida.com/foro/post.php?tid={self.config.thread_id}') + #self._post = self.browser.get_form(id='postear') + #self._post['cuerpo'].value = message - self.browser.submit_form(self._post) + #self.browser.submit_form(self._post) - return self.browser.url + #return self.browser.url - def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.DataFrame, vote_majority:int, post_id:int) -> str: + def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.DataFrame, vote_majority:int, post_id:int, event_class:str="") -> str: """Generate a formatted Markdown message representing the vote count results. Args: @@ -185,8 +185,11 @@ def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.Data Returns: str: A string formatted in Markdown suitable to be posted as a new message in mediavida.com """ - - self._header = "# Recuento de votos \n" + if not event_class: + self._header = "# Recuento de votos \n" + else: + self._header = f"# Recuento para {event_class} \n" + self._votes_rank = self.generate_string_from_vote_count(vote_count) self._non_voters = list(set(alive_players) - set(vote_count["voted_by"].values.tolist())) self._non_voters = ", ".join(self._non_voters) From 1aaf482040d0245388f0ab8d24be93584689263b Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 16:16:04 +0200 Subject: [PATCH 03/12] Add jail event action --- states/action.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/states/action.py b/states/action.py index ddd0f59..55a1928 100644 --- a/states/action.py +++ b/states/action.py @@ -16,6 +16,8 @@ class Action(enum.Enum): winner = "ganador" shoot = "disparo" revive = "reanimar" + jail = "encierro" + unjail = "libero" From 135e0e23530c2a5558b134e1091d4ffc7c3f183a Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 21:19:44 +0200 Subject: [PATCH 04/12] Add stub for mvote count --- main.py | 19 +- modules/game_actions.py | 2 + states/action.py | 2 + test_jail_count.py | 460 ++++++++++++++++++++++++++++++++++++++++ user.py | 19 +- vote_count.py | 1 - 6 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 test_jail_count.py diff --git a/main.py b/main.py index d2db710..c9c0f09 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ import config import user import vote_count +import test_jail_count import player_list as pl import modules.thread_reader as tr import states.stage as stages @@ -90,6 +91,13 @@ def run(update_tick: int): bot_cycle=bot_cycles, n_players=len(Players.players) ) + + JailEventCount = test_jail_count.JailCount( + staff=staff, + day_start_post=current_day_start_post, + bot_cycle=bot_cycles, + n_players=len(Players.players) + ) print('We are on day time!') @@ -126,6 +134,7 @@ def run(update_tick: int): resolve_action_queue(queue=action_queue, vcount=VoteCount, + ecount=JailEventCount, Players=Players, last_count=last_votecount_id, day_start = current_day_start_post, @@ -205,7 +214,7 @@ def run(update_tick: int): #TODO: Handle actual permissions without a giant if/else #TODO: This func. is prime candidate for refactoring -def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, Players: pl.Players, last_count:int, day_start:int, eod_time:int): +def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, ecount: test_jail_count.JailCount, Players: pl.Players, last_count:int, day_start:int, eod_time:int): ''' Parameters: \n queue: A list of game actions.\n @@ -238,10 +247,16 @@ def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, Players: pl. reveal=f"{Players.get_player_role(game_action.victim)} - {Players.get_player_team(game_action.victim)}" ) break - + elif game_action.type == actions.Action.unvote: vcount.unvote_player(action=game_action) + elif game_action.type == actions.Action.jail and Players.player_exists(game_action.victim): + ecount.vote_player(action=game_action) + + elif game_action.type == actions.Action.unjail: + ecount.unvote_player(action=game_action) + elif game_action.type == actions.Action.lylo: logging.info(f'{game_action.author} requested an vcount lock at {game_action.id}') diff --git a/modules/game_actions.py b/modules/game_actions.py index 6666a28..c58459b 100644 --- a/modules/game_actions.py +++ b/modules/game_actions.py @@ -17,7 +17,9 @@ def __init__(self, post_id: int, post_time: int, contents: str, author: str): ## Available commands self._action_responses = { actions.Action.vote: self._parse_vote, + actions.Action.jail: self._parse_vote, actions.Action.unvote: self._parse_unvote, + actions.Action.unjail: self._parse_unvote, actions.Action.replace_player: self._replace_player, actions.Action.request_count: self._parse_vote_count_request, actions.Action.vote_history: self._set_victim_from_arg, diff --git a/states/action.py b/states/action.py index 55a1928..ce41978 100644 --- a/states/action.py +++ b/states/action.py @@ -18,6 +18,8 @@ class Action(enum.Enum): revive = "reanimar" jail = "encierro" unjail = "libero" + pyre = "condeno" + unpyre = "perdono" diff --git a/test_jail_count.py b/test_jail_count.py new file mode 100644 index 0000000..4fb7920 --- /dev/null +++ b/test_jail_count.py @@ -0,0 +1,460 @@ +import math + +import logging +import pandas as pd + +import modules.game_actions as gm + +class JailCount: + + def __init__(self, staff:list, day_start_post:int, bot_cycle:int, n_players: int): + + # Initialize empty vote table + self._vote_table = pd.DataFrame(columns=["player", + "public_name", "voted_by", + "voted_as", "post_id", + "post_time", "bot_cycle"]) + + # Load vote rights table + self.vote_rights = pd.read_csv('vote_config.csv', sep=',') + + # use lowercase player names as keys, player column as true names + self.vote_rights.index = self.vote_rights['player'].str.lower() + + # get the major (if any) + self.vote_rights["is_mayor"] = self.vote_rights["is_mayor"].astype(bool) + + if self.vote_rights.loc[:, "is_mayor"].any(): + self.mayor = self.vote_rights.loc[self.vote_rights["is_mayor"], :].index[0] + else: + self.mayor = None + + self.staff = staff + + self.lynched_player = '' + self.bot_cycle = bot_cycle + + # Frozen vote players + self.frozen_players = list() + + self.locked_unvotes = False + + self.current_majority = self.get_vote_majority(n_players = n_players) + self.day_start_post = day_start_post + + + def player_exists(self, player:str) -> bool: + """Check if a given player is in the vote_rights table. + + Args: + player (str): The player to check + + Returns: + bool: False if the player does not exists. True otherwise. + """ + if player.lower() in self.vote_rights.index: + return True + else: + return False + + + def get_real_names(self) -> dict: + """Get a dictionary of the player names with proper casing. + + Returns: + dict: A dict with lowercased player names as keys and properly cased names as values. + """ + + self._real_names = self.vote_rights['player'].to_dict() + self._staff_to_gm = {self._staff.lower(): 'GM' for self._staff in self.staff} + self._real_names.update(self._staff_to_gm) + self._real_names.update({'no_lynch':'No linchamiento'}) + + return self._real_names + + + def get_vote_majority(self, n_players:int) -> int: + """Calculate the amount of votes necessary to reach an absolute majority + and lynch a player based on the amount of alive players. + + Args: + n_players (int): The amount of alive players. + + Returns: + int: The absolute majority of votes required to lynch a player. + """ + self._majority = math.ceil(n_players / 2) + + if n_players % 2 == 0: + self._majority += 1 + + ## now set the half majority and the offset + self._majority = round(math.floor(self._majority / 2) + 1) + + return self._majority + + + def get_player_current_votes(self, player:str) -> int: + """Count current casted votes by a given player. + + Args: + player (str): The player whose votes are to be counted. + + Returns: + int: The number of valid votes casted by said player. + """ + self._player_current_votes = len(self._vote_table[self._vote_table['voted_by'] == player]) + + return self._player_current_votes + + + def get_victim_current_votes(self, victim:str) -> int: + """Count current votes on a given player. + + Args: + victim (str): The player whose votes are to be counted + + Returns: + int: The number of valid votes casted on said player. + """ + self._lynch_votes = len(self._vote_table[self._vote_table['player'] == victim]) + + return self._lynch_votes + + + def get_player_mod_to_lynch(self, player:str) -> int: + """Get the majority modifier of a given player. + + Args: + player (str): + + Returns: + int: The majority modifier of the requested player + """ + + return 0 + + + def vote_player(self, action: gm.GameAction): + """Parse a vote player action. + + Args: + action (gm.GameAction): The game action featuring the vote. + """ + + if self.is_valid_vote(action.author, action.victim): + + ## Get the real MV names, with the proper casing, and the GM + ## alias for staffers + + self._names_to_use = self.get_real_names() + + ## By default, set the author and victim to the action lowercased ids + self._voter_real_name = action.author + self._victim_real_name = self._names_to_use[action.victim] + + ## If a member from the staff uses an alias, overwrite any author name. + if action.author in self.staff and action.author != action.alias: + self._voter_real_name = action.alias + else: + self._voter_real_name = self._names_to_use[action.author] + + self._append_vote(player=action.author, + victim=action.victim, + post_id=action.id, + post_time=action.post_time, + victim_alias=self._victim_real_name, + voted_as=self._voter_real_name) + + + def unvote_player(self, action: gm.GameAction): + """Attempt to parse an unvote action. + + Args: + action (gm.GameAction): The action triggering the unvote. + """ + + if self.is_valid_unvote(action.author, action.victim): + self._remove_vote(action.author, action.victim, action.id) + + + def is_lynched(self, victim:str) -> bool: + """ Check if a given player has received enough votes to be lynched. This + function evaluates if a given player accumulates enough votes by calculating + the current absolute majority required and adding to it a player specific + lynch modifier as defined in the vote rights table. + + Args: + victim (str): The player who receives the vote. + + Returns: + bool: True if the player should be lynched. False otherwise. + """ + self._lynched = False + + # Count this player votes + self._lynch_votes = self.get_victim_current_votes(victim) + self._player_majority = self.current_majority + self.get_player_mod_to_lynch(victim) + + if self._lynch_votes >= self._player_majority: + self._lynched = True + + return self._lynched + + + def is_valid_vote(self, player:str, victim:str) -> bool: + """Evaluate if a given vote is valid. A valid vote has to fulfill the following + requirements: + + a) The victim can be voted: alive, playing and set as vote candidate in the vote rights table.\n + b) The voting player must have casted less votes than their current limit.\n + c) No other special conditions (i.e. freezing) which prevent the cast of the vote apply.\n + + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the vote.\n + + + Returns: + bool: True if the vote is valid, False otherwise. + """ + self._is_valid_vote = False + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_vote + + if player in self.staff: + self._player_max_votes = 999 + else: + if self.player_exists(player): + self._player_max_votes = self.vote_rights.loc[player, 'allowed_votes'] + else: + logging.error(f'{player} is not in the vote_rights table. Invalid jail!') + return False + + self._player_current_votes = self.get_player_current_votes(player) + + if self._player_current_votes < self._player_max_votes: + + if victim in self.vote_rights.index: + self._victim_can_be_voted = bool(self.vote_rights.loc[victim, 'can_be_voted']) + + if self._victim_can_be_voted: + self._is_valid_vote = True + + else: + logging.info(f'{player} jailed non-votable player: {victim}') + else: + logging.error(f'{victim} not found in vote_rights.csv') + else: + logging.info(f'{player} has reached maximum allowed jail votes') + + return self._is_valid_vote + + + def is_valid_unvote(self, player:str, victim:str) -> bool: + """Evaluate if a given unvote is valid. A valid unvote has to fulfill the following + requirements: + + a)The player has previously casted a voted to victim + b)The player has at least one casted vote if victim = 'none' + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the unvote the vote. Can be none for a general unvote + + Returns: + bool: True if the unvote is valid, False otherwise. + """ + self._is_valid_unvote = False + + if self.locked_unvotes: + logging.info(f'Ignoring unjail casted by {player} due to LyLo.') + return self._is_valid_unvote + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_unvote + + if player in self._vote_table['voted_by'].values: + + if self.get_player_current_votes(player) > 0: + + if victim == 'none': + self._is_valid_unvote = True + + else: + # Get all casted voted to said victim by player + self._casted_votes = self._vote_table[(self._vote_table['player'] == victim ) & (self._vote_table['voted_by'] == player)] + + if len(self._casted_votes) > 0: + self._is_valid_unvote = True + + return self._is_valid_unvote + + + def replace_player(self, replaced:str, replaced_by:str): + """Attempt to replace a given player by another one. The substitute + inherits all the votes casted to him as well as the votes casted by the + replaced player. + + Args: + replaced (str): The replaced player. + replaced_by (str): The substitute. + """ + + if self.player_exists(player=replaced): + + ## Update the votetable. This is run-safe. + self._vote_table.loc[self._vote_table['player'] == replaced, ['player', 'public_name']] = replaced_by + self._vote_table.loc[self._vote_table['voted_by'] == replaced, ['voted_by', 'voted_as']] = replaced_by + + ## Update the vote rights, do not edit it. It would invalidate the votes casted to the replaced + ## player on the next run. + if not self.player_exists(player=replaced_by): + self._append_to_vote_rights(player=replaced_by, based_on_player=replaced) + + ## Check if the replaced player was frozen and update + if replaced in self.frozen_players: + self.frozen_players.remove(replaced) + self.frozen_players.append(replaced_by) + + else: + logging.warning(f'Attempting to replace unknown player {replaced} with {replaced_by}') + + + def remove_player(self, player_to_remove:str): + """Attempt to remove a player from the game. This means eliminating all + the votes casted to and casted by this player. + + Args: + player_to_remove (str): The player to remove. + """ + if self.player_exists(player=player_to_remove): + + self._votes_by_player = self._vote_table['player'] == player_to_remove + self._voted_by_player = self._vote_table['voted_by'] == player_to_remove + + self._entries_to_drop = self._vote_table[self._votes_by_player | self._voted_by_player].index + self._vote_table.drop(self._entries_to_drop, axis=0, inplace=True) + + logging.info(f'Remove:{player_to_remove}') + else: + logging.warning(f'Attempting to remove invalid player from the vcount {player_to_remove}') + + + def freeze_player_votes(self, frozen_player:str): + """Attempt to append a player to the list of frozen players. + + Args: + frozen_player (str): The player to append. + """ + + if frozen_player not in self.staff: + if self.player_exists(player=frozen_player): + if frozen_player not in self.frozen_players: + self.frozen_players.append(frozen_player) + else: + logging.info(f'{frozen_player} is already frozen.') + else: + logging.warning(f'Cannot freeze player {frozen_player}. Check vote_config.csv') + else: + logging.warning(f'Cannot freeze staff member {frozen_player}.') + + + def lock_unvotes(self): + """ + Lock the vote count, so that players will not be able to unvote for the + remaining of the day once a vote is casted. + """ + if not self.locked_unvotes: + self.locked_unvotes = True + logging.info('The vote count has been locked') + + def update_vote_limits(self, player:str, new_limit:int): + """Change the max. amount of votes for a given player + + Args: + player (str): The player whose vote limit will be updated + new_limit (int): The new limit. If lower than 0, it will be set to 0 + """ + if self.player_exists(player): + self._new_limit = new_limit if new_limit >= 0 else 0 + self._old_limit = self.vote_rights.loc[player, "allowed_votes"] + + if self._old_limit != self._new_limit: + self.vote_rights.loc[player, 'allowed_votes'] = self._new_limit + self.vote_rights.to_csv("vote_config.csv", sep=",", index=False, header=True) + else: + logging.info(f"Ignoring vote rights update for player {player}") + else: + logging.warning(f"Attempting to update vote limit for unknown id: {player}.") + + def get_current_lynch_candidate(self) -> str: + """Returns the player with the most votes at the given time. If there are no votes, it will default + to no_lynch or None if no_lynch is not allowed. + + Returns: + str: Player to lynch + """ + + self._most_voted = self._vote_table["public_name"].value_counts().sort_values(ascending=False) + + if len(self._most_voted) == 0: + # Is no lynch allowed? + self._is_no_lynch_allowed = bool(self.vote_rights.loc["no_lynch", "can_be_voted"]) + + if self._is_no_lynch_allowed: + return "no_lynch" + else: + return None + elif len(self._most_voted) == 1: + return self._most_voted.index[0] + ## TIE... do not resolve if tie with no lynch. Let the GM decide for now + else: + return None + + + + #TODO: Awful function, fix it + def _append_vote(self, player:str, victim:str, post_id:int, post_time:int, victim_alias:str, voted_as:str): + """Append a new vote to the vote count. + + Args: + player (str): The (lowercased) casting the vote. + victim (str): The (lowercased) player receiving the vote. + post_id (int): The post number where the vote was casted. + post_time (int): The UNIX epoch time of the post where the vote was casted. + victim_alias (str): The real name (properly cased) of the player receiving the vote. + voted_as (str): The vote alias of the player casting the v ote. + """ + self._vote_table = self._vote_table.append({'player': victim, + 'voted_by': player, + 'post_id': post_id, + 'post_time': post_time, + 'public_name': victim_alias, + 'voted_as': voted_as, + 'bot_cycle': self.bot_cycle}, + ignore_index=True) + + logging.info(f'{player} jailed {victim} at {post_id}') + + + def _remove_vote(self, player:str, victim:str, unvote_post:int): + """Remove a given vote from the vote table. They are always + removed from the oldest to the newest casted vote. + + Args: + player (str): The player who removes the vote. + victim (str): The unvoted player. Can be set to "none" to remove the oldest vote no matter the victim. + """ + if victim == 'none': ## Remove the oldest vote + self._old_vote = self._vote_table[self._vote_table['voted_by'] == player].index[0] + else: + self._old_vote = self._vote_table[(self._vote_table['player'] == victim) & (self._vote_table['voted_by'] == player)].index[0] + + self._vote_table.drop(self._old_vote, axis=0, inplace=True) + + logging.info(f'{player} unjailed {victim}.') \ No newline at end of file diff --git a/user.py b/user.py index e141f2d..cf8b10a 100644 --- a/user.py +++ b/user.py @@ -55,7 +55,6 @@ def push_queue(self): self.post(self._resolved_queue) self.clear_queue() - def push_votecount(self, vote_count:pd.DataFrame, alive_players:pd.DataFrame, vote_majority:int, post_id:int): """Generate a new vote count message and push it to the game thread. Skips the queue. @@ -71,7 +70,18 @@ def push_votecount(self, vote_count:pd.DataFrame, alive_players:pd.DataFrame, vo vote_majority=vote_majority, post_id=post_id) self.post(self._message_to_post) - + + def queue_jailcount(self, jail_count:pd.DataFrame, alive_players:pd.DataFrame, vote_majority:int, post_id:int): + self._message_to_post = self.generate_vote_message( + jail_count, + alive_players, + vote_majority, + post_id, + event_class="el calabozo" + ) + + self._queue.append(self._message_to_post) + def push_new_mayor(self, new_mayor:str): self._header = '# ¡El alcalde del pueblo aparece! \n' self._body = f"**¡{new_mayor} se revela para liderar al pueblo!** \n\n" @@ -172,6 +182,7 @@ def post(self, message:str): #self.browser.submit_form(self._post) #return self.browser.url + print(message) def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.DataFrame, vote_majority:int, post_id:int, event_class:str="") -> str: """Generate a formatted Markdown message representing the vote count results. @@ -188,8 +199,8 @@ def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.Data if not event_class: self._header = "# Recuento de votos \n" else: - self._header = f"# Recuento para {event_class} \n" - + self._header = f"# Recuento para {event_class} \n [img]https://i.pinimg.com/736x/25/33/a9/2533a9d852fff6af3353da97cc4052f1.jpg[/img] \n" + self._votes_rank = self.generate_string_from_vote_count(vote_count) self._non_voters = list(set(alive_players) - set(vote_count["voted_by"].values.tolist())) self._non_voters = ", ".join(self._non_voters) diff --git a/vote_count.py b/vote_count.py index 026aff0..e9db68e 100644 --- a/vote_count.py +++ b/vote_count.py @@ -553,4 +553,3 @@ def _append_to_vote_rights(self, player:str, based_on_player:str): #TODO: Find a better way to do this. logging.info(f'Updated vote_rights.csv with {player}') self.vote_rights.to_csv('vote_config.csv', sep=',', index=False, header=True) - From 889b663fc974e29226588ec255f04fec98eb7add Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 21:42:43 +0200 Subject: [PATCH 05/12] Add stubs for pyre and exile --- main.py | 16 ++ modules/game_actions.py | 4 + states/action.py | 2 + test_exile_count.py | 460 ++++++++++++++++++++++++++++++++++++++++ test_pyre_count.py | 460 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 942 insertions(+) create mode 100644 test_exile_count.py create mode 100644 test_pyre_count.py diff --git a/main.py b/main.py index c9c0f09..0a92fc2 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,8 @@ import user import vote_count import test_jail_count +import test_exile_count +import test_pyre_count import player_list as pl import modules.thread_reader as tr import states.stage as stages @@ -98,6 +100,20 @@ def run(update_tick: int): bot_cycle=bot_cycles, n_players=len(Players.players) ) + + ExileEventCount = test_exile_count.ExileCount( + staff=staff, + day_start_post=current_day_start_post, + bot_cycle=bot_cycles, + n_players=len(Players.players) + ) + + PyreEventCount = test_pyre_count.PyreCount( + staff=staff, + day_start_post=current_day_start_post, + bot_cycle=bot_cycles, + n_players=len(Players.players) + ) print('We are on day time!') diff --git a/modules/game_actions.py b/modules/game_actions.py index c58459b..3e08935 100644 --- a/modules/game_actions.py +++ b/modules/game_actions.py @@ -20,6 +20,10 @@ def __init__(self, post_id: int, post_time: int, contents: str, author: str): actions.Action.jail: self._parse_vote, actions.Action.unvote: self._parse_unvote, actions.Action.unjail: self._parse_unvote, + actions.Action.pyre: self._parse_vote, + actions.Action.unpyre: self._parse_unvote, + actions.Action.exile: self._parse_vote, + actions.Action.unexile: self._parse_unvote, actions.Action.replace_player: self._replace_player, actions.Action.request_count: self._parse_vote_count_request, actions.Action.vote_history: self._set_victim_from_arg, diff --git a/states/action.py b/states/action.py index ce41978..fc1e7a4 100644 --- a/states/action.py +++ b/states/action.py @@ -20,6 +20,8 @@ class Action(enum.Enum): unjail = "libero" pyre = "condeno" unpyre = "perdono" + exile = "exilio" + unexile = "acojo" diff --git a/test_exile_count.py b/test_exile_count.py new file mode 100644 index 0000000..950dc86 --- /dev/null +++ b/test_exile_count.py @@ -0,0 +1,460 @@ +import math + +import logging +import pandas as pd + +import modules.game_actions as gm + +class ExileCount: + + def __init__(self, staff:list, day_start_post:int, bot_cycle:int, n_players: int): + + # Initialize empty vote table + self._vote_table = pd.DataFrame(columns=["player", + "public_name", "voted_by", + "voted_as", "post_id", + "post_time", "bot_cycle"]) + + # Load vote rights table + self.vote_rights = pd.read_csv('vote_config.csv', sep=',') + + # use lowercase player names as keys, player column as true names + self.vote_rights.index = self.vote_rights['player'].str.lower() + + # get the major (if any) + self.vote_rights["is_mayor"] = self.vote_rights["is_mayor"].astype(bool) + + if self.vote_rights.loc[:, "is_mayor"].any(): + self.mayor = self.vote_rights.loc[self.vote_rights["is_mayor"], :].index[0] + else: + self.mayor = None + + self.staff = staff + + self.lynched_player = '' + self.bot_cycle = bot_cycle + + # Frozen vote players + self.frozen_players = list() + + self.locked_unvotes = False + + self.current_majority = self.get_vote_majority(n_players = n_players) + self.day_start_post = day_start_post + + + def player_exists(self, player:str) -> bool: + """Check if a given player is in the vote_rights table. + + Args: + player (str): The player to check + + Returns: + bool: False if the player does not exists. True otherwise. + """ + if player.lower() in self.vote_rights.index: + return True + else: + return False + + + def get_real_names(self) -> dict: + """Get a dictionary of the player names with proper casing. + + Returns: + dict: A dict with lowercased player names as keys and properly cased names as values. + """ + + self._real_names = self.vote_rights['player'].to_dict() + self._staff_to_gm = {self._staff.lower(): 'GM' for self._staff in self.staff} + self._real_names.update(self._staff_to_gm) + self._real_names.update({'no_lynch':'No linchamiento'}) + + return self._real_names + + + def get_vote_majority(self, n_players:int) -> int: + """Calculate the amount of votes necessary to reach an absolute majority + and lynch a player based on the amount of alive players. + + Args: + n_players (int): The amount of alive players. + + Returns: + int: The absolute majority of votes required to lynch a player. + """ + self._majority = math.ceil(n_players / 2) + + if n_players % 2 == 0: + self._majority += 1 + + ## now set the half majority and the offset + self._majority = round(math.floor(self._majority / 2) + 2) + + return self._majority + + + def get_player_current_votes(self, player:str) -> int: + """Count current casted votes by a given player. + + Args: + player (str): The player whose votes are to be counted. + + Returns: + int: The number of valid votes casted by said player. + """ + self._player_current_votes = len(self._vote_table[self._vote_table['voted_by'] == player]) + + return self._player_current_votes + + + def get_victim_current_votes(self, victim:str) -> int: + """Count current votes on a given player. + + Args: + victim (str): The player whose votes are to be counted + + Returns: + int: The number of valid votes casted on said player. + """ + self._lynch_votes = len(self._vote_table[self._vote_table['player'] == victim]) + + return self._lynch_votes + + + def get_player_mod_to_lynch(self, player:str) -> int: + """Get the majority modifier of a given player. + + Args: + player (str): + + Returns: + int: The majority modifier of the requested player + """ + + return 0 + + + def vote_player(self, action: gm.GameAction): + """Parse a vote player action. + + Args: + action (gm.GameAction): The game action featuring the vote. + """ + + if self.is_valid_vote(action.author, action.victim): + + ## Get the real MV names, with the proper casing, and the GM + ## alias for staffers + + self._names_to_use = self.get_real_names() + + ## By default, set the author and victim to the action lowercased ids + self._voter_real_name = action.author + self._victim_real_name = self._names_to_use[action.victim] + + ## If a member from the staff uses an alias, overwrite any author name. + if action.author in self.staff and action.author != action.alias: + self._voter_real_name = action.alias + else: + self._voter_real_name = self._names_to_use[action.author] + + self._append_vote(player=action.author, + victim=action.victim, + post_id=action.id, + post_time=action.post_time, + victim_alias=self._victim_real_name, + voted_as=self._voter_real_name) + + + def unvote_player(self, action: gm.GameAction): + """Attempt to parse an unvote action. + + Args: + action (gm.GameAction): The action triggering the unvote. + """ + + if self.is_valid_unvote(action.author, action.victim): + self._remove_vote(action.author, action.victim, action.id) + + + def is_lynched(self, victim:str) -> bool: + """ Check if a given player has received enough votes to be lynched. This + function evaluates if a given player accumulates enough votes by calculating + the current absolute majority required and adding to it a player specific + lynch modifier as defined in the vote rights table. + + Args: + victim (str): The player who receives the vote. + + Returns: + bool: True if the player should be lynched. False otherwise. + """ + self._lynched = False + + # Count this player votes + self._lynch_votes = self.get_victim_current_votes(victim) + self._player_majority = self.current_majority + self.get_player_mod_to_lynch(victim) + + if self._lynch_votes >= self._player_majority: + self._lynched = True + + return self._lynched + + + def is_valid_vote(self, player:str, victim:str) -> bool: + """Evaluate if a given vote is valid. A valid vote has to fulfill the following + requirements: + + a) The victim can be voted: alive, playing and set as vote candidate in the vote rights table.\n + b) The voting player must have casted less votes than their current limit.\n + c) No other special conditions (i.e. freezing) which prevent the cast of the vote apply.\n + + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the vote.\n + + + Returns: + bool: True if the vote is valid, False otherwise. + """ + self._is_valid_vote = False + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_vote + + if player in self.staff: + self._player_max_votes = 999 + else: + if self.player_exists(player): + self._player_max_votes = self.vote_rights.loc[player, 'allowed_votes'] + else: + logging.error(f'{player} is not in the vote_rights table. Invalid jail!') + return False + + self._player_current_votes = self.get_player_current_votes(player) + + if self._player_current_votes < self._player_max_votes: + + if victim in self.vote_rights.index: + self._victim_can_be_voted = bool(self.vote_rights.loc[victim, 'can_be_voted']) + + if self._victim_can_be_voted: + self._is_valid_vote = True + + else: + logging.info(f'{player} jailed non-votable player: {victim}') + else: + logging.error(f'{victim} not found in vote_rights.csv') + else: + logging.info(f'{player} has reached maximum allowed jail votes') + + return self._is_valid_vote + + + def is_valid_unvote(self, player:str, victim:str) -> bool: + """Evaluate if a given unvote is valid. A valid unvote has to fulfill the following + requirements: + + a)The player has previously casted a voted to victim + b)The player has at least one casted vote if victim = 'none' + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the unvote the vote. Can be none for a general unvote + + Returns: + bool: True if the unvote is valid, False otherwise. + """ + self._is_valid_unvote = False + + if self.locked_unvotes: + logging.info(f'Ignoring unjail casted by {player} due to LyLo.') + return self._is_valid_unvote + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_unvote + + if player in self._vote_table['voted_by'].values: + + if self.get_player_current_votes(player) > 0: + + if victim == 'none': + self._is_valid_unvote = True + + else: + # Get all casted voted to said victim by player + self._casted_votes = self._vote_table[(self._vote_table['player'] == victim ) & (self._vote_table['voted_by'] == player)] + + if len(self._casted_votes) > 0: + self._is_valid_unvote = True + + return self._is_valid_unvote + + + def replace_player(self, replaced:str, replaced_by:str): + """Attempt to replace a given player by another one. The substitute + inherits all the votes casted to him as well as the votes casted by the + replaced player. + + Args: + replaced (str): The replaced player. + replaced_by (str): The substitute. + """ + + if self.player_exists(player=replaced): + + ## Update the votetable. This is run-safe. + self._vote_table.loc[self._vote_table['player'] == replaced, ['player', 'public_name']] = replaced_by + self._vote_table.loc[self._vote_table['voted_by'] == replaced, ['voted_by', 'voted_as']] = replaced_by + + ## Update the vote rights, do not edit it. It would invalidate the votes casted to the replaced + ## player on the next run. + if not self.player_exists(player=replaced_by): + self._append_to_vote_rights(player=replaced_by, based_on_player=replaced) + + ## Check if the replaced player was frozen and update + if replaced in self.frozen_players: + self.frozen_players.remove(replaced) + self.frozen_players.append(replaced_by) + + else: + logging.warning(f'Attempting to replace unknown player {replaced} with {replaced_by}') + + + def remove_player(self, player_to_remove:str): + """Attempt to remove a player from the game. This means eliminating all + the votes casted to and casted by this player. + + Args: + player_to_remove (str): The player to remove. + """ + if self.player_exists(player=player_to_remove): + + self._votes_by_player = self._vote_table['player'] == player_to_remove + self._voted_by_player = self._vote_table['voted_by'] == player_to_remove + + self._entries_to_drop = self._vote_table[self._votes_by_player | self._voted_by_player].index + self._vote_table.drop(self._entries_to_drop, axis=0, inplace=True) + + logging.info(f'Remove:{player_to_remove}') + else: + logging.warning(f'Attempting to remove invalid player from the vcount {player_to_remove}') + + + def freeze_player_votes(self, frozen_player:str): + """Attempt to append a player to the list of frozen players. + + Args: + frozen_player (str): The player to append. + """ + + if frozen_player not in self.staff: + if self.player_exists(player=frozen_player): + if frozen_player not in self.frozen_players: + self.frozen_players.append(frozen_player) + else: + logging.info(f'{frozen_player} is already frozen.') + else: + logging.warning(f'Cannot freeze player {frozen_player}. Check vote_config.csv') + else: + logging.warning(f'Cannot freeze staff member {frozen_player}.') + + + def lock_unvotes(self): + """ + Lock the vote count, so that players will not be able to unvote for the + remaining of the day once a vote is casted. + """ + if not self.locked_unvotes: + self.locked_unvotes = True + logging.info('The vote count has been locked') + + def update_vote_limits(self, player:str, new_limit:int): + """Change the max. amount of votes for a given player + + Args: + player (str): The player whose vote limit will be updated + new_limit (int): The new limit. If lower than 0, it will be set to 0 + """ + if self.player_exists(player): + self._new_limit = new_limit if new_limit >= 0 else 0 + self._old_limit = self.vote_rights.loc[player, "allowed_votes"] + + if self._old_limit != self._new_limit: + self.vote_rights.loc[player, 'allowed_votes'] = self._new_limit + self.vote_rights.to_csv("vote_config.csv", sep=",", index=False, header=True) + else: + logging.info(f"Ignoring vote rights update for player {player}") + else: + logging.warning(f"Attempting to update vote limit for unknown id: {player}.") + + def get_current_lynch_candidate(self) -> str: + """Returns the player with the most votes at the given time. If there are no votes, it will default + to no_lynch or None if no_lynch is not allowed. + + Returns: + str: Player to lynch + """ + + self._most_voted = self._vote_table["public_name"].value_counts().sort_values(ascending=False) + + if len(self._most_voted) == 0: + # Is no lynch allowed? + self._is_no_lynch_allowed = bool(self.vote_rights.loc["no_lynch", "can_be_voted"]) + + if self._is_no_lynch_allowed: + return "no_lynch" + else: + return None + elif len(self._most_voted) == 1: + return self._most_voted.index[0] + ## TIE... do not resolve if tie with no lynch. Let the GM decide for now + else: + return None + + + + #TODO: Awful function, fix it + def _append_vote(self, player:str, victim:str, post_id:int, post_time:int, victim_alias:str, voted_as:str): + """Append a new vote to the vote count. + + Args: + player (str): The (lowercased) casting the vote. + victim (str): The (lowercased) player receiving the vote. + post_id (int): The post number where the vote was casted. + post_time (int): The UNIX epoch time of the post where the vote was casted. + victim_alias (str): The real name (properly cased) of the player receiving the vote. + voted_as (str): The vote alias of the player casting the v ote. + """ + self._vote_table = self._vote_table.append({'player': victim, + 'voted_by': player, + 'post_id': post_id, + 'post_time': post_time, + 'public_name': victim_alias, + 'voted_as': voted_as, + 'bot_cycle': self.bot_cycle}, + ignore_index=True) + + logging.info(f'{player} jailed {victim} at {post_id}') + + + def _remove_vote(self, player:str, victim:str, unvote_post:int): + """Remove a given vote from the vote table. They are always + removed from the oldest to the newest casted vote. + + Args: + player (str): The player who removes the vote. + victim (str): The unvoted player. Can be set to "none" to remove the oldest vote no matter the victim. + """ + if victim == 'none': ## Remove the oldest vote + self._old_vote = self._vote_table[self._vote_table['voted_by'] == player].index[0] + else: + self._old_vote = self._vote_table[(self._vote_table['player'] == victim) & (self._vote_table['voted_by'] == player)].index[0] + + self._vote_table.drop(self._old_vote, axis=0, inplace=True) + + logging.info(f'{player} unjailed {victim}.') \ No newline at end of file diff --git a/test_pyre_count.py b/test_pyre_count.py new file mode 100644 index 0000000..e4f7ce9 --- /dev/null +++ b/test_pyre_count.py @@ -0,0 +1,460 @@ +import math + +import logging +import pandas as pd + +import modules.game_actions as gm + +class PyreCount: + + def __init__(self, staff:list, day_start_post:int, bot_cycle:int, n_players: int): + + # Initialize empty vote table + self._vote_table = pd.DataFrame(columns=["player", + "public_name", "voted_by", + "voted_as", "post_id", + "post_time", "bot_cycle"]) + + # Load vote rights table + self.vote_rights = pd.read_csv('vote_config.csv', sep=',') + + # use lowercase player names as keys, player column as true names + self.vote_rights.index = self.vote_rights['player'].str.lower() + + # get the major (if any) + self.vote_rights["is_mayor"] = self.vote_rights["is_mayor"].astype(bool) + + if self.vote_rights.loc[:, "is_mayor"].any(): + self.mayor = self.vote_rights.loc[self.vote_rights["is_mayor"], :].index[0] + else: + self.mayor = None + + self.staff = staff + + self.lynched_player = '' + self.bot_cycle = bot_cycle + + # Frozen vote players + self.frozen_players = list() + + self.locked_unvotes = False + + self.current_majority = self.get_vote_majority(n_players = n_players) + self.day_start_post = day_start_post + + + def player_exists(self, player:str) -> bool: + """Check if a given player is in the vote_rights table. + + Args: + player (str): The player to check + + Returns: + bool: False if the player does not exists. True otherwise. + """ + if player.lower() in self.vote_rights.index: + return True + else: + return False + + + def get_real_names(self) -> dict: + """Get a dictionary of the player names with proper casing. + + Returns: + dict: A dict with lowercased player names as keys and properly cased names as values. + """ + + self._real_names = self.vote_rights['player'].to_dict() + self._staff_to_gm = {self._staff.lower(): 'GM' for self._staff in self.staff} + self._real_names.update(self._staff_to_gm) + self._real_names.update({'no_lynch':'No linchamiento'}) + + return self._real_names + + + def get_vote_majority(self, n_players:int) -> int: + """Calculate the amount of votes necessary to reach an absolute majority + and lynch a player based on the amount of alive players. + + Args: + n_players (int): The amount of alive players. + + Returns: + int: The absolute majority of votes required to lynch a player. + """ + self._majority = math.ceil(n_players / 2) + + if n_players % 2 == 0: + self._majority += 1 + + ## now set the half majority and the offset + self._majority = round(math.floor(self._majority / 2) + 3) + + return self._majority + + + def get_player_current_votes(self, player:str) -> int: + """Count current casted votes by a given player. + + Args: + player (str): The player whose votes are to be counted. + + Returns: + int: The number of valid votes casted by said player. + """ + self._player_current_votes = len(self._vote_table[self._vote_table['voted_by'] == player]) + + return self._player_current_votes + + + def get_victim_current_votes(self, victim:str) -> int: + """Count current votes on a given player. + + Args: + victim (str): The player whose votes are to be counted + + Returns: + int: The number of valid votes casted on said player. + """ + self._lynch_votes = len(self._vote_table[self._vote_table['player'] == victim]) + + return self._lynch_votes + + + def get_player_mod_to_lynch(self, player:str) -> int: + """Get the majority modifier of a given player. + + Args: + player (str): + + Returns: + int: The majority modifier of the requested player + """ + + return 0 + + + def vote_player(self, action: gm.GameAction): + """Parse a vote player action. + + Args: + action (gm.GameAction): The game action featuring the vote. + """ + + if self.is_valid_vote(action.author, action.victim): + + ## Get the real MV names, with the proper casing, and the GM + ## alias for staffers + + self._names_to_use = self.get_real_names() + + ## By default, set the author and victim to the action lowercased ids + self._voter_real_name = action.author + self._victim_real_name = self._names_to_use[action.victim] + + ## If a member from the staff uses an alias, overwrite any author name. + if action.author in self.staff and action.author != action.alias: + self._voter_real_name = action.alias + else: + self._voter_real_name = self._names_to_use[action.author] + + self._append_vote(player=action.author, + victim=action.victim, + post_id=action.id, + post_time=action.post_time, + victim_alias=self._victim_real_name, + voted_as=self._voter_real_name) + + + def unvote_player(self, action: gm.GameAction): + """Attempt to parse an unvote action. + + Args: + action (gm.GameAction): The action triggering the unvote. + """ + + if self.is_valid_unvote(action.author, action.victim): + self._remove_vote(action.author, action.victim, action.id) + + + def is_lynched(self, victim:str) -> bool: + """ Check if a given player has received enough votes to be lynched. This + function evaluates if a given player accumulates enough votes by calculating + the current absolute majority required and adding to it a player specific + lynch modifier as defined in the vote rights table. + + Args: + victim (str): The player who receives the vote. + + Returns: + bool: True if the player should be lynched. False otherwise. + """ + self._lynched = False + + # Count this player votes + self._lynch_votes = self.get_victim_current_votes(victim) + self._player_majority = self.current_majority + self.get_player_mod_to_lynch(victim) + + if self._lynch_votes >= self._player_majority: + self._lynched = True + + return self._lynched + + + def is_valid_vote(self, player:str, victim:str) -> bool: + """Evaluate if a given vote is valid. A valid vote has to fulfill the following + requirements: + + a) The victim can be voted: alive, playing and set as vote candidate in the vote rights table.\n + b) The voting player must have casted less votes than their current limit.\n + c) No other special conditions (i.e. freezing) which prevent the cast of the vote apply.\n + + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the vote.\n + + + Returns: + bool: True if the vote is valid, False otherwise. + """ + self._is_valid_vote = False + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_vote + + if player in self.staff: + self._player_max_votes = 999 + else: + if self.player_exists(player): + self._player_max_votes = self.vote_rights.loc[player, 'allowed_votes'] + else: + logging.error(f'{player} is not in the vote_rights table. Invalid jail!') + return False + + self._player_current_votes = self.get_player_current_votes(player) + + if self._player_current_votes < self._player_max_votes: + + if victim in self.vote_rights.index: + self._victim_can_be_voted = bool(self.vote_rights.loc[victim, 'can_be_voted']) + + if self._victim_can_be_voted: + self._is_valid_vote = True + + else: + logging.info(f'{player} jailed non-votable player: {victim}') + else: + logging.error(f'{victim} not found in vote_rights.csv') + else: + logging.info(f'{player} has reached maximum allowed jail votes') + + return self._is_valid_vote + + + def is_valid_unvote(self, player:str, victim:str) -> bool: + """Evaluate if a given unvote is valid. A valid unvote has to fulfill the following + requirements: + + a)The player has previously casted a voted to victim + b)The player has at least one casted vote if victim = 'none' + + Args: + player (str): The player casting the vote. + victim (str): The player who receives the unvote the vote. Can be none for a general unvote + + Returns: + bool: True if the unvote is valid, False otherwise. + """ + self._is_valid_unvote = False + + if self.locked_unvotes: + logging.info(f'Ignoring unjail casted by {player} due to LyLo.') + return self._is_valid_unvote + + # If the player votes are frozen then we have nothing to do here + if player in self.frozen_players: + return self._is_valid_unvote + + if player in self._vote_table['voted_by'].values: + + if self.get_player_current_votes(player) > 0: + + if victim == 'none': + self._is_valid_unvote = True + + else: + # Get all casted voted to said victim by player + self._casted_votes = self._vote_table[(self._vote_table['player'] == victim ) & (self._vote_table['voted_by'] == player)] + + if len(self._casted_votes) > 0: + self._is_valid_unvote = True + + return self._is_valid_unvote + + + def replace_player(self, replaced:str, replaced_by:str): + """Attempt to replace a given player by another one. The substitute + inherits all the votes casted to him as well as the votes casted by the + replaced player. + + Args: + replaced (str): The replaced player. + replaced_by (str): The substitute. + """ + + if self.player_exists(player=replaced): + + ## Update the votetable. This is run-safe. + self._vote_table.loc[self._vote_table['player'] == replaced, ['player', 'public_name']] = replaced_by + self._vote_table.loc[self._vote_table['voted_by'] == replaced, ['voted_by', 'voted_as']] = replaced_by + + ## Update the vote rights, do not edit it. It would invalidate the votes casted to the replaced + ## player on the next run. + if not self.player_exists(player=replaced_by): + self._append_to_vote_rights(player=replaced_by, based_on_player=replaced) + + ## Check if the replaced player was frozen and update + if replaced in self.frozen_players: + self.frozen_players.remove(replaced) + self.frozen_players.append(replaced_by) + + else: + logging.warning(f'Attempting to replace unknown player {replaced} with {replaced_by}') + + + def remove_player(self, player_to_remove:str): + """Attempt to remove a player from the game. This means eliminating all + the votes casted to and casted by this player. + + Args: + player_to_remove (str): The player to remove. + """ + if self.player_exists(player=player_to_remove): + + self._votes_by_player = self._vote_table['player'] == player_to_remove + self._voted_by_player = self._vote_table['voted_by'] == player_to_remove + + self._entries_to_drop = self._vote_table[self._votes_by_player | self._voted_by_player].index + self._vote_table.drop(self._entries_to_drop, axis=0, inplace=True) + + logging.info(f'Remove:{player_to_remove}') + else: + logging.warning(f'Attempting to remove invalid player from the vcount {player_to_remove}') + + + def freeze_player_votes(self, frozen_player:str): + """Attempt to append a player to the list of frozen players. + + Args: + frozen_player (str): The player to append. + """ + + if frozen_player not in self.staff: + if self.player_exists(player=frozen_player): + if frozen_player not in self.frozen_players: + self.frozen_players.append(frozen_player) + else: + logging.info(f'{frozen_player} is already frozen.') + else: + logging.warning(f'Cannot freeze player {frozen_player}. Check vote_config.csv') + else: + logging.warning(f'Cannot freeze staff member {frozen_player}.') + + + def lock_unvotes(self): + """ + Lock the vote count, so that players will not be able to unvote for the + remaining of the day once a vote is casted. + """ + if not self.locked_unvotes: + self.locked_unvotes = True + logging.info('The vote count has been locked') + + def update_vote_limits(self, player:str, new_limit:int): + """Change the max. amount of votes for a given player + + Args: + player (str): The player whose vote limit will be updated + new_limit (int): The new limit. If lower than 0, it will be set to 0 + """ + if self.player_exists(player): + self._new_limit = new_limit if new_limit >= 0 else 0 + self._old_limit = self.vote_rights.loc[player, "allowed_votes"] + + if self._old_limit != self._new_limit: + self.vote_rights.loc[player, 'allowed_votes'] = self._new_limit + self.vote_rights.to_csv("vote_config.csv", sep=",", index=False, header=True) + else: + logging.info(f"Ignoring vote rights update for player {player}") + else: + logging.warning(f"Attempting to update vote limit for unknown id: {player}.") + + def get_current_lynch_candidate(self) -> str: + """Returns the player with the most votes at the given time. If there are no votes, it will default + to no_lynch or None if no_lynch is not allowed. + + Returns: + str: Player to lynch + """ + + self._most_voted = self._vote_table["public_name"].value_counts().sort_values(ascending=False) + + if len(self._most_voted) == 0: + # Is no lynch allowed? + self._is_no_lynch_allowed = bool(self.vote_rights.loc["no_lynch", "can_be_voted"]) + + if self._is_no_lynch_allowed: + return "no_lynch" + else: + return None + elif len(self._most_voted) == 1: + return self._most_voted.index[0] + ## TIE... do not resolve if tie with no lynch. Let the GM decide for now + else: + return None + + + + #TODO: Awful function, fix it + def _append_vote(self, player:str, victim:str, post_id:int, post_time:int, victim_alias:str, voted_as:str): + """Append a new vote to the vote count. + + Args: + player (str): The (lowercased) casting the vote. + victim (str): The (lowercased) player receiving the vote. + post_id (int): The post number where the vote was casted. + post_time (int): The UNIX epoch time of the post where the vote was casted. + victim_alias (str): The real name (properly cased) of the player receiving the vote. + voted_as (str): The vote alias of the player casting the v ote. + """ + self._vote_table = self._vote_table.append({'player': victim, + 'voted_by': player, + 'post_id': post_id, + 'post_time': post_time, + 'public_name': victim_alias, + 'voted_as': voted_as, + 'bot_cycle': self.bot_cycle}, + ignore_index=True) + + logging.info(f'{player} jailed {victim} at {post_id}') + + + def _remove_vote(self, player:str, victim:str, unvote_post:int): + """Remove a given vote from the vote table. They are always + removed from the oldest to the newest casted vote. + + Args: + player (str): The player who removes the vote. + victim (str): The unvoted player. Can be set to "none" to remove the oldest vote no matter the victim. + """ + if victim == 'none': ## Remove the oldest vote + self._old_vote = self._vote_table[self._vote_table['voted_by'] == player].index[0] + else: + self._old_vote = self._vote_table[(self._vote_table['player'] == victim) & (self._vote_table['voted_by'] == player)].index[0] + + self._vote_table.drop(self._old_vote, axis=0, inplace=True) + + logging.info(f'{player} unjailed {victim}.') \ No newline at end of file From 8a130238b7112a48e1d25cdcda5d6f6994aef30a Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 22:08:20 +0200 Subject: [PATCH 06/12] Get rid of shameful if/else --- main.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 0a92fc2..3015bee 100644 --- a/main.py +++ b/main.py @@ -114,6 +114,16 @@ def run(update_tick: int): bot_cycle=bot_cycles, n_players=len(Players.players) ) + + ## Try to make this less shameful + event_counts = { + actions.Action.exile: ExileEventCount, + actions.Action.unexile: ExileEventCount, + actions.Action.pyre: PyreEventCount, + actions.Action.unpyre: PyreEventCount, + actions.Action.unjail: JailEventCount, + actions.Action.jail: JailEventCount + } print('We are on day time!') @@ -150,7 +160,7 @@ def run(update_tick: int): resolve_action_queue(queue=action_queue, vcount=VoteCount, - ecount=JailEventCount, + ecounts=event_counts, Players=Players, last_count=last_votecount_id, day_start = current_day_start_post, @@ -230,7 +240,7 @@ def run(update_tick: int): #TODO: Handle actual permissions without a giant if/else #TODO: This func. is prime candidate for refactoring -def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, ecount: test_jail_count.JailCount, Players: pl.Players, last_count:int, day_start:int, eod_time:int): +def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, ecounts: dict, Players: pl.Players, last_count:int, day_start:int, eod_time:int): ''' Parameters: \n queue: A list of game actions.\n @@ -264,6 +274,13 @@ def resolve_action_queue(queue: list, vcount: vote_count.VoteCount, ecount: test ) break + elif game_action.type in ecounts.keys(): + ## some kind of unvote + if "un" in game_action.type.name: + ecounts[game_action.type].unvote_player(action=game_action) + else: + ecounts[game_action.type].vote_player(action=game_action) + elif game_action.type == actions.Action.unvote: vcount.unvote_player(action=game_action) From 3790ef17eecb0b517a24ab533d92a7afce889426 Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 22:25:06 +0200 Subject: [PATCH 07/12] Fix log entries --- test_exile_count.py | 6 +++--- test_pyre_count.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test_exile_count.py b/test_exile_count.py index 950dc86..901670c 100644 --- a/test_exile_count.py +++ b/test_exile_count.py @@ -245,7 +245,7 @@ def is_valid_vote(self, player:str, victim:str) -> bool: self._is_valid_vote = True else: - logging.info(f'{player} jailed non-votable player: {victim}') + logging.info(f'{player} exiled non-votable player: {victim}') else: logging.error(f'{victim} not found in vote_rights.csv') else: @@ -439,7 +439,7 @@ def _append_vote(self, player:str, victim:str, post_id:int, post_time:int, victi 'bot_cycle': self.bot_cycle}, ignore_index=True) - logging.info(f'{player} jailed {victim} at {post_id}') + logging.info(f'{player} exiled {victim} at {post_id}') def _remove_vote(self, player:str, victim:str, unvote_post:int): @@ -457,4 +457,4 @@ def _remove_vote(self, player:str, victim:str, unvote_post:int): self._vote_table.drop(self._old_vote, axis=0, inplace=True) - logging.info(f'{player} unjailed {victim}.') \ No newline at end of file + logging.info(f'{player} unexiled {victim}.') \ No newline at end of file diff --git a/test_pyre_count.py b/test_pyre_count.py index e4f7ce9..c109192 100644 --- a/test_pyre_count.py +++ b/test_pyre_count.py @@ -245,7 +245,7 @@ def is_valid_vote(self, player:str, victim:str) -> bool: self._is_valid_vote = True else: - logging.info(f'{player} jailed non-votable player: {victim}') + logging.info(f'{player} pyred non-votable player: {victim}') else: logging.error(f'{victim} not found in vote_rights.csv') else: @@ -439,7 +439,7 @@ def _append_vote(self, player:str, victim:str, post_id:int, post_time:int, victi 'bot_cycle': self.bot_cycle}, ignore_index=True) - logging.info(f'{player} jailed {victim} at {post_id}') + logging.info(f'{player} pyred {victim} at {post_id}') def _remove_vote(self, player:str, victim:str, unvote_post:int): @@ -457,4 +457,4 @@ def _remove_vote(self, player:str, victim:str, unvote_post:int): self._vote_table.drop(self._old_vote, axis=0, inplace=True) - logging.info(f'{player} unjailed {victim}.') \ No newline at end of file + logging.info(f'{player} unpyred {victim}.') \ No newline at end of file From 3494aafd46ce0c1deef26086b113117c72fca7ec Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 23:23:32 +0200 Subject: [PATCH 08/12] Stub for updater --- main.py | 24 ++++++++++++++---- user.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 3015bee..44232b0 100644 --- a/main.py +++ b/main.py @@ -203,11 +203,25 @@ def run(update_tick: int): if should_update: logging.info('Pushing a new votecount') - push_vote_count(vote_table=VoteCount._vote_table, - alive_players=Players.players, - last_parsed_post=last_thread_post, - current_majority=VoteCount.current_majority - ) + User = user.User(config=settings) + vcounts = { + actions.Action.vote: VoteCount, + actions.Action.jail: JailEventCount, + actions.Action.pyre: PyreEventCount, + actions.Action.exile: ExileEventCount + } + User.push_all_counts( + vcounts=vcounts, + alive_players=Players.players, + post_id=last_thread_post + ) + del User + # push_vote_count(vote_table=VoteCount._vote_table, + # alive_players=Players.players, + # last_parsed_post=last_thread_post, + # current_majority=VoteCount.current_majority + # ) + else: logging.info('Recent votecount detected. ') diff --git a/user.py b/user.py index cf8b10a..7e8a105 100644 --- a/user.py +++ b/user.py @@ -21,7 +21,8 @@ def __init__(self, config: object): # Attempt to log into MV with these credentials. #TODO: Log errors here self.config = config - self.browser = self.login(self.config.mediavida_user, self.config.mediavida_pwd) + #TODO: enable login again here? + #self.browser = self.login(self.config.mediavida_user, self.config.mediavida_pwd) # Init internal queue self._queue = list() @@ -70,18 +71,35 @@ def push_votecount(self, vote_count:pd.DataFrame, alive_players:pd.DataFrame, vo vote_majority=vote_majority, post_id=post_id) self.post(self._message_to_post) - - def queue_jailcount(self, jail_count:pd.DataFrame, alive_players:pd.DataFrame, vote_majority:int, post_id:int): - self._message_to_post = self.generate_vote_message( - jail_count, - alive_players, - vote_majority, - post_id, - event_class="el calabozo" - ) - - self._queue.append(self._message_to_post) - + + + def push_all_counts(self, vcounts:dict, alive_players: pd.DataFrame, post_id:int): + self._full_message = "" + + for vcount_name, vcount in vcounts.items(): + self._header_set = { + "vote": "", + "exile": "el exilio \n [img]https://i.pinimg.com/736x/25/33/a9/2533a9d852fff6af3353da97cc4052f1.jpg[/img]", + "pyre": "la pira \n", + "jail": "el calabozo \n" + } + + self._this_vcount_msg = self.generate_vote_message2( + vote_count=vcount._vote_table, + alive_players=alive_players, + vote_majority=vcount.current_majority, + post_id=post_id, + event_class= self._header_set[vcount_name.name] + ) + self._full_message = self._full_message + self._this_vcount_msg + + self._updated = (f'_Actualizado hasta el mensaje: {post_id}._ \n \n') + self._bot_ad = "**Soy un bot de recuento automático. Por favor, no me cites _¡N'wah!_** \n" + + self._full_message = self._full_message + self._updated + self._bot_ad + self.post(self._full_message) + + def push_new_mayor(self, new_mayor:str): self._header = '# ¡El alcalde del pueblo aparece! \n' self._body = f"**¡{new_mayor} se revela para liderar al pueblo!** \n\n" @@ -175,6 +193,8 @@ def post(self, message:str): Returns: [Robobrowser]: The resolved form. """ + self.browser = self.login(self.config.mediavida_user, self.config.mediavida_pwd) + #self.browser.open(f'http://www.mediavida.com/foro/post.php?tid={self.config.thread_id}') #self._post = self.browser.get_form(id='postear') #self._post['cuerpo'].value = message @@ -199,7 +219,7 @@ def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.Data if not event_class: self._header = "# Recuento de votos \n" else: - self._header = f"# Recuento para {event_class} \n [img]https://i.pinimg.com/736x/25/33/a9/2533a9d852fff6af3353da97cc4052f1.jpg[/img] \n" + self._header = f"# Recuento para {event_class}\n" self._votes_rank = self.generate_string_from_vote_count(vote_count) self._non_voters = list(set(alive_players) - set(vote_count["voted_by"].values.tolist())) @@ -213,6 +233,34 @@ def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.Data self._message = self._header + self._votes_rank + self._non_voters_msg + "\n" + self._footer + self._updated + self._bot_ad return self._message + + def generate_vote_message2(self, vote_count: pd.DataFrame, alive_players: pd.DataFrame, vote_majority:int, post_id:int, event_class:str="") -> str: + """Generate a formatted Markdown message representing the vote count results. + + Args: + vote_count (pd.DataFrame): The vote count to parse. + alive_players (int): The number of alive players. + vote_majority (int): The current number of votes to reach abs.majority. + post_id (int): The post id of the last vote parsed in the vote_count. + + Returns: + str: A string formatted in Markdown suitable to be posted as a new message in mediavida.com + """ + if not event_class: + self._header = "# Recuento de votos \n" + else: + self._header = f"# Recuento para {event_class} \n" + + self._votes_rank = self.generate_string_from_vote_count(vote_count) + self._non_voters = list(set(alive_players) - set(vote_count["voted_by"].values.tolist())) + self._non_voters = ", ".join(self._non_voters) + + self._non_voters_msg = (f"1. **No han votado:** {self._non_voters}.\n") + self._footer = (f'_Con {len(alive_players)} jugadores vivos, la mayoría se alcanza con {vote_majority} votos._ \n') + + self._message = self._header + self._votes_rank + self._non_voters_msg + "\n" + self._footer + "\n" + + return self._message def generate_lynch_message(self, last_votecount: pd.DataFrame, victim:str, post_id:int, role:str="Unknown") ->str: From 79979445ca90e98b546a4864063cbb6d0597c8e8 Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 1 May 2024 23:27:12 +0200 Subject: [PATCH 09/12] Temp stub for pusher --- user.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/user.py b/user.py index 7e8a105..50ee72e 100644 --- a/user.py +++ b/user.py @@ -79,7 +79,7 @@ def push_all_counts(self, vcounts:dict, alive_players: pd.DataFrame, post_id:int for vcount_name, vcount in vcounts.items(): self._header_set = { "vote": "", - "exile": "el exilio \n [img]https://i.pinimg.com/736x/25/33/a9/2533a9d852fff6af3353da97cc4052f1.jpg[/img]", + "exile": "el exilio \n", "pyre": "la pira \n", "jail": "el calabozo \n" } @@ -195,14 +195,14 @@ def post(self, message:str): """ self.browser = self.login(self.config.mediavida_user, self.config.mediavida_pwd) - #self.browser.open(f'http://www.mediavida.com/foro/post.php?tid={self.config.thread_id}') - #self._post = self.browser.get_form(id='postear') - #self._post['cuerpo'].value = message + self.browser.open(f'http://www.mediavida.com/foro/post.php?tid={self.config.thread_id}') + self._post = self.browser.get_form(id='postear') + self._post['cuerpo'].value = message - #self.browser.submit_form(self._post) - - #return self.browser.url + self.browser.submit_form(self._post) print(message) + + return self.browser.url def generate_vote_message(self, vote_count: pd.DataFrame, alive_players: pd.DataFrame, vote_majority:int, post_id:int, event_class:str="") -> str: """Generate a formatted Markdown message representing the vote count results. From 96908f192554aec611238ad09d1dde391e7692df Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 15 May 2024 12:10:00 +0200 Subject: [PATCH 10/12] Fix vcount bug --- vote_count.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vote_count.py b/vote_count.py index e9db68e..5de1b6f 100644 --- a/vote_count.py +++ b/vote_count.py @@ -484,7 +484,7 @@ def _set_unvote_to_history(self, player:str, victim:str, unvote_post_id): ] if victim != "none": - self._unvote = self._unvote.loc["player" == victim] + self._unvote = self._unvote.loc[self._unvote["player"] == victim] self._sorted_unvotes = self._unvote.sort_values("bot_cycle") From 463927c1beaa1919839ee56c26b4c89ae52dcf3b Mon Sep 17 00:00:00 2001 From: Santiago Garcia Date: Wed, 15 May 2024 12:10:15 +0200 Subject: [PATCH 11/12] Fix action list bug --- main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 44232b0..89f7238 100644 --- a/main.py +++ b/main.py @@ -170,7 +170,14 @@ def run(update_tick: int): ## Check If there is still time left to play. Otherwise, start the EoD if game_status.is_end_of_stage(current_time = get_current_ntp_time()) and not majority_reached: - last_valid_action = [action for action in action_queue if action.post_time < game_status.get_end_of_stage()].pop() + last_action_list = [action for action in action_queue if action.post_time < game_status.get_end_of_stage()] + + if last_action_list: + last_valid_action = last_action_list.pop() + last_valid_action_post = last_valid_action.id + else: + last_valid_action_post = 0 + majority_reached = True ## This will stop the bot in the next iteration logging.info("EoD detected. Pushing last valid votecount and preparing flip routine") From 03751fdc84940aa7a51bca12c0300e86aa179133 Mon Sep 17 00:00:00 2001 From: SGMartin Date: Mon, 27 May 2024 11:20:31 +0200 Subject: [PATCH 12/12] Add new z tags for posts --- modules/thread_reader.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/thread_reader.py b/modules/thread_reader.py index e801c12..0ef1e2e 100644 --- a/modules/thread_reader.py +++ b/modules/thread_reader.py @@ -340,7 +340,13 @@ def get_actions_from_page(game_thread:str, page_to_scan:int, start_from_post:int # I'm ignoring edit div elements because players should not edit while # playing. - posts = parser.findAll('div', class_ = ['cf post','cf post z','cf post first']) + posts = parser.findAll('div', class_ = [ + 'cf post', + 'cf post z', + 'cf post first', + 'cf post op', + 'cf post z op' + ]) for post in posts: