From a4a23312c5711b2c2f9317da20a1371252b8ed4d Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 11:57:06 +0800 Subject: [PATCH 1/8] Can detect AFK players Count rounds in which players do nothing Check if 2 rounds or more when picking Modified _pick_possible for use with AFK checking --- src/model/match.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index f12070b..f0dc6af 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -94,6 +94,10 @@ class Match: # Whether matches are currently frozen frozen = False + # Records which players appear to be AFK + # Counts the number of rounds in which each player did nothing + afk_players = {} + @classmethod @named_mutex("_pool_lock") def get_by_id(cls, id): @@ -358,8 +362,17 @@ def _enter_state(self): # Remove all hands that are not completed for picking self._unchoose_incomplete() + # Determine if pick is possible + can_pick = self._pick_possible() + + # Kick AFK players + for (player, afkRounds) in self.afk_players.items(): + if afkRounds >= 2: + # Kick player for doing nothing for two rounds + self._chat.append(("SYSTEM", player + " has been AFK for two rounds!")) + # If no pick is possible (too few valid hands) then skip the round - if not self._pick_possible(): + if not can_pick: self._chat.append(("SYSTEM", "Too few valid choices!")) # If the round is skipped only unchoose the cards without @@ -563,6 +576,7 @@ def add_participant(self, part): self._participants[id] = part if not part.spectator: self._chat.append(("SYSTEM", "%s joined." % nick)) + self.afk_players[nick] = 0 else: self._chat.append(("SYSTEM", "%s is now spectating." % nick)) @@ -855,11 +869,13 @@ def _pick_possible(self): """ n = 0 for part in self.get_participants(False): + # Find players who haven't played if part.choose_count() > 0: n += 1 - if n == 2: - return True - return False + self.afk_players[part.nickname] = 0 + else: + self.afk_players[part.nickname] += 1 + return n >= 2 def _unchoose_incomplete(self): """Unchooses incomplete hands. From b87904c40e67efb3f6726528a823c77974852d23 Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 12:15:48 +0800 Subject: [PATCH 2/8] Ungracefully kick AFK users Track AFK users by PID rather than name Kicked users just stop updating --- src/model/match.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index f0dc6af..14f651b 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -366,10 +366,10 @@ def _enter_state(self): can_pick = self._pick_possible() # Kick AFK players - for (player, afkRounds) in self.afk_players.items(): + for (pid, afkRounds) in self.afk_players.items(): if afkRounds >= 2: # Kick player for doing nothing for two rounds - self._chat.append(("SYSTEM", player + " has been AFK for two rounds!")) + self.abandon_participant(pid) # If no pick is possible (too few valid hands) then skip the round if not can_pick: @@ -576,7 +576,7 @@ def add_participant(self, part): self._participants[id] = part if not part.spectator: self._chat.append(("SYSTEM", "%s joined." % nick)) - self.afk_players[nick] = 0 + self.afk_players[id] = 0 else: self._chat.append(("SYSTEM", "%s is now spectating." % nick)) @@ -872,9 +872,9 @@ def _pick_possible(self): # Find players who haven't played if part.choose_count() > 0: n += 1 - self.afk_players[part.nickname] = 0 + self.afk_players[part.id] = 0 else: - self.afk_players[part.nickname] += 1 + self.afk_players[part.id] += 1 return n >= 2 def _unchoose_incomplete(self): From 34e374490b967f53143aa7c8da082cc50107a691 Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 12:54:29 +0800 Subject: [PATCH 3/8] Can use custom message for abandon_participant Abandoning participant takes default argument "left." Kicking for AFK uses custom message Pickers not choosing no longer considered AFK --- src/model/match.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index 14f651b..68342e1 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -369,7 +369,7 @@ def _enter_state(self): for (pid, afkRounds) in self.afk_players.items(): if afkRounds >= 2: # Kick player for doing nothing for two rounds - self.abandon_participant(pid) + self.abandon_participant(pid, "was kicked for being AFK for two rounds.") # If no pick is possible (too few valid hands) then skip the round if not can_pick: @@ -466,7 +466,7 @@ def check_participants(self): del self._participants[pid] @mutex - def abandon_participant(self, pid): + def abandon_participant(self, pid, message="left."): """Removes the given participant from the match. Args: @@ -480,7 +480,7 @@ def abandon_participant(self, pid): return nick = self._participants[pid].nickname self._chat.append(("SYSTEM", - "%s left." % nick)) + "%s %s" % (nick, message))) if self._participants[pid].picking: self.notify_picker_leave(pid) del self._participants[pid] @@ -873,7 +873,7 @@ def _pick_possible(self): if part.choose_count() > 0: n += 1 self.afk_players[part.id] = 0 - else: + elif not part.picking: self.afk_players[part.id] += 1 return n >= 2 From f0d85db33900719e4cd2fd862b01942c2cb495c7 Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 13:16:12 +0800 Subject: [PATCH 4/8] Kick on cooldown rather than picking Mark picker as AFK for a round if no winner is picked Stop tracking player's AFK rounds when participant abandoned --- src/model/match.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index 68342e1..72b058e 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -362,17 +362,8 @@ def _enter_state(self): # Remove all hands that are not completed for picking self._unchoose_incomplete() - # Determine if pick is possible - can_pick = self._pick_possible() - - # Kick AFK players - for (pid, afkRounds) in self.afk_players.items(): - if afkRounds >= 2: - # Kick player for doing nothing for two rounds - self.abandon_participant(pid, "was kicked for being AFK for two rounds.") - # If no pick is possible (too few valid hands) then skip the round - if not can_pick: + if not self._pick_possible(): self._chat.append(("SYSTEM", "Too few valid choices!")) # If the round is skipped only unchoose the cards without @@ -389,6 +380,10 @@ def _enter_state(self): self._timer = time() + pick_time elif self._state == "COOLDOWN": self._timer = time() + Match._TIMER_COOLDOWN + # Kick AFK players for doing nothing for two rounds + for (pid, afkRounds) in self.afk_players.items(): + if afkRounds >= 2: + self.abandon_participant(pid, "was kicked for being AFK for two rounds.") elif self._state == "ENDING": self._timer = time() + Match._TIMER_ENDING @@ -434,6 +429,14 @@ def check_timer(self): elif self._state == "CHOOSING": self._set_state("PICKING") elif self._state == "PICKING": + # If no winner was picked, mark picker as AFK + picker = None + for part in self.get_participants(False): + if part.picking: + picker = part + break + assert picker is not None + self.afk_players[picker.id] += 1 self._chat.append(("SYSTEM", "No winner was picked!")) self._set_state("COOLDOWN") @@ -464,6 +467,7 @@ def check_participants(self): if part.picking: self.notify_picker_leave(pid) del self._participants[pid] + del self.afk_players[pid] @mutex def abandon_participant(self, pid, message="left."): From 3a875d1a603c02a78ccb730a5f0c61b193950d48 Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 13:29:11 +0800 Subject: [PATCH 5/8] Picking a winner resets AFK rounds --- src/model/match.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/model/match.py b/src/model/match.py index 72b058e..78c63cf 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -818,6 +818,8 @@ def declare_round_winner(self, order): winner = None for part in self.get_participants(False): if part.picking or part.choose_count() < gc: + if part.picking: + self.afk_players[part.id] = 0 continue if part.order == order: winner = part From a6a990bdc64b03e646d452035c0c167a2632f61e Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 18:36:45 +0800 Subject: [PATCH 6/8] Provide participant as arg for chat send Reset AFK round count on chat send --- src/model/match.py | 7 ++++--- src/pages/api.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index 78c63cf..c005118 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -787,11 +787,11 @@ def retrieve_chat(self, offset=0): return res @mutex - def send_message(self, nick, msg): + def send_message(self, part, msg): """Sends a user message to the chat of this match. Args: - nick (str): The nickname of the user. + part (Participant): The user who sent the message. msg (str): The message that is sent to the chat. Contract: @@ -800,7 +800,8 @@ def send_message(self, nick, msg): msg = re.sub("(https?://\\S+)", "\\1", msg) - self._chat.append(("USER", "%s: %s" % (nick, msg))) + self._chat.append(("USER", "%s: %s" % (part.nickname, msg))) + self.afk_players[part.id] = 0 @mutex def declare_round_winner(self, order): diff --git a/src/pages/api.py b/src/pages/api.py index bb891bd..2f61e72 100644 --- a/src/pages/api.py +++ b/src/pages/api.py @@ -286,7 +286,7 @@ def api_chat_send(ctx: EndpointContext) -> None: # Check the chat message for sanity if 0 < len(msg) < 200: # Send the message - match.send_message(part.nickname, msg) + match.send_message(part, msg) ctx.json_ok() else: raise HTTPException.forbidden(True, "invalid size") From d5e84a9cde5d097509277c97ec8483ba516b72aa Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 19:18:40 +0800 Subject: [PATCH 7/8] Moved AFK round count to Participant Added some documentation --- src/model/match.py | 26 ++++++++++++-------------- src/model/participant.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/model/match.py b/src/model/match.py index c005118..fad64e6 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -94,10 +94,6 @@ class Match: # Whether matches are currently frozen frozen = False - # Records which players appear to be AFK - # Counts the number of rounds in which each player did nothing - afk_players = {} - @classmethod @named_mutex("_pool_lock") def get_by_id(cls, id): @@ -381,9 +377,11 @@ def _enter_state(self): elif self._state == "COOLDOWN": self._timer = time() + Match._TIMER_COOLDOWN # Kick AFK players for doing nothing for two rounds - for (pid, afkRounds) in self.afk_players.items(): - if afkRounds >= 2: - self.abandon_participant(pid, "was kicked for being AFK for two rounds.") + participants = list(self.get_participants(False))[:] + for part in participants: + if part.afkCount >= 2: + self.abandon_participant(part.id, + "was kicked for being AFK for two rounds.") elif self._state == "ENDING": self._timer = time() + Match._TIMER_ENDING @@ -436,7 +434,7 @@ def check_timer(self): picker = part break assert picker is not None - self.afk_players[picker.id] += 1 + picker.increase_AFK() self._chat.append(("SYSTEM", "No winner was picked!")) self._set_state("COOLDOWN") @@ -467,7 +465,6 @@ def check_participants(self): if part.picking: self.notify_picker_leave(pid) del self._participants[pid] - del self.afk_players[pid] @mutex def abandon_participant(self, pid, message="left."): @@ -475,6 +472,8 @@ def abandon_participant(self, pid, message="left."): Args: pid (str): The ID of the participant. + message (str): The message to send when the user leaves without + the nickname. Defaults to "left." Contract: This method locks the match's instance lock and the participant's @@ -580,7 +579,6 @@ def add_participant(self, part): self._participants[id] = part if not part.spectator: self._chat.append(("SYSTEM", "%s joined." % nick)) - self.afk_players[id] = 0 else: self._chat.append(("SYSTEM", "%s is now spectating." % nick)) @@ -801,7 +799,7 @@ def send_message(self, part, msg): "\\1", msg) self._chat.append(("USER", "%s: %s" % (part.nickname, msg))) - self.afk_players[part.id] = 0 + part.reset_AFK() @mutex def declare_round_winner(self, order): @@ -820,7 +818,7 @@ def declare_round_winner(self, order): for part in self.get_participants(False): if part.picking or part.choose_count() < gc: if part.picking: - self.afk_players[part.id] = 0 + part.reset_AFK() continue if part.order == order: winner = part @@ -879,9 +877,9 @@ def _pick_possible(self): # Find players who haven't played if part.choose_count() > 0: n += 1 - self.afk_players[part.id] = 0 + part.reset_AFK() elif not part.picking: - self.afk_players[part.id] += 1 + part.increase_AFK() return n >= 2 def _unchoose_incomplete(self): diff --git a/src/model/participant.py b/src/model/participant.py index 0346a34..938bdf1 100644 --- a/src/model/participant.py +++ b/src/model/participant.py @@ -52,6 +52,7 @@ class Participant: occurring. order: The order key of the particpant, used for shuffling. spectator: Whether the participant is a spectator. + afkCount: The number of rounds the participant has spent AFK. """ # The number of hand cards per type @@ -60,6 +61,9 @@ class Participant: # The timeout timer after refreshing a participant, in seconds _PARTICIPANT_REFRESH_TIMER = 15 + # Rounds spent AFK + afkCount = 0 + def __init__(self, id: str, nickname: str) -> None: """Constructor. @@ -117,6 +121,24 @@ def increase_score(self) -> None: assert not self.spectator, "Trying to increase score for spectator" self.score += 1 + @mutex + def increase_AFK(self) -> None: + """Increases AFK count by one. + + Contract: + This method locks the particpant's lock. + """ + self.afkCount += 1 + + @mutex + def reset_AFK(self) -> None: + """Reset the particpant's AFK count to zero. + + Contract: + This method locks the particpant's lock. + """ + self.afkCount = 0 + def refresh(self) -> None: """Refreshes the timeout timer of this participant.""" # Locking is not needed here as access is atomic. From 867217747700f43ee9dca93d978f52e9d385c270 Mon Sep 17 00:00:00 2001 From: Arc676/Alessandro Vinciguerra Date: Sat, 3 Feb 2018 19:40:03 +0800 Subject: [PATCH 8/8] AFK round count limit now configurable Default 2 rounds --- src/model/match.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/model/match.py b/src/model/match.py index fad64e6..4998b41 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -36,6 +36,7 @@ from random import shuffle from threading import RLock from time import time +from nussschale.nussschale import nconfig from model.multideck import MultiDeck from nussschale.util.locks import mutex, named_mutex @@ -51,6 +52,8 @@ class Match: Class Attributes: frozen (bool): Whether matches are currently frozen, i.e. whether their state transitions are disabled. + afk_limit (int): Maximum number of rounds a participant can spend AFK + before they are kicked from the game. """ # The minimum amount of players for a match @@ -94,6 +97,9 @@ class Match: # Whether matches are currently frozen frozen = False + # Limit on number of AFK rounds before kicking players + afk_limit = 2 + @classmethod @named_mutex("_pool_lock") def get_by_id(cls, id): @@ -228,6 +234,9 @@ def __init__(self): # The chat of this match, tuples with type/message self._chat = [("SYSTEM", "Match was created.")] + # The limit on the number of AFK rounds before kicking players + self.afk_limit = nconfig().get("afk-limit", 2) + def put_in_pool(self): """Puts this match into the match pool.""" Match.add_match(self.id, self) @@ -379,7 +388,7 @@ def _enter_state(self): # Kick AFK players for doing nothing for two rounds participants = list(self.get_participants(False))[:] for part in participants: - if part.afkCount >= 2: + if part.afkCount >= self.afk_limit: self.abandon_participant(part.id, "was kicked for being AFK for two rounds.") elif self._state == "ENDING":