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")