diff --git a/src/model/match.py b/src/model/match.py index e884725..ff31201 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) @@ -412,6 +421,12 @@ 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 + participants = list(self.get_participants(False))[:] + for part in participants: + if part.afkCount >= self.afk_limit: + self.abandon_participant(part.id, + "was kicked for being AFK for two rounds.") elif self._state == "ENDING": self._timer = time() + Match._TIMER_ENDING @@ -457,6 +472,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 + picker.increase_AFK() self._chat.append(("SYSTEM", "No winner was picked!")) self._set_state("COOLDOWN") @@ -489,11 +512,13 @@ 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: 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 @@ -503,7 +528,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] @@ -805,11 +830,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: @@ -818,7 +843,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))) + part.reset_AFK() @mutex def declare_round_winner(self, order): @@ -836,6 +862,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: + part.reset_AFK() continue if part.order == order: winner = part @@ -891,11 +919,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 + part.reset_AFK() + elif not part.picking: + part.increase_AFK() + return n >= 2 def _unchoose_incomplete(self): """Unchooses incomplete hands. 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. diff --git a/src/pages/api.py b/src/pages/api.py index c4678a4..526d3a6 100644 --- a/src/pages/api.py +++ b/src/pages/api.py @@ -287,7 +287,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")