Skip to content

Instantly share code, notes, and snippets.

@gabrielfgularte
Last active May 24, 2023 05:15
Show Gist options
  • Save gabrielfgularte/f15f3e753c7024a1b9bc769f3ff02cc5 to your computer and use it in GitHub Desktop.
Save gabrielfgularte/f15f3e753c7024a1b9bc769f3ff02cc5 to your computer and use it in GitHub Desktop.
from __future__ import annotations
import random
from datetime import datetime
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext as _
from pydantic import BaseModel
from core.redis import RedisClient
from core.utils import str_to_timezone
from matchmaking.models import Player
from .invite import LobbyInvite
cache = RedisClient()
User = get_user_model()
class LobbyException(Exception):
pass
class Lobby(BaseModel):
"""
This model represents the lobbies on Redis cache db.
The Redis db keys from this model are described below:
[key] __mm:lobby:[player_id] <current_lobby_id>
[set] __mm:lobby:[player_id]:players <(player_id,...)>
[key] __mm:lobby:[player_id]:is_public <0|1>
[zset] __mm:lobby:[player_id]:invites <(from_player_id:to_player_id,...)>
[key] __mm:lobby:[player_id]:queue <datetime>
[key] __mm:lobby:[player_id]:type <competitive|custom>
[key] __mm:lobby:[player_id]:mode <1|5|20>
"""
owner_id: int
class Config:
CACHE_PREFIX: str = '__mm:lobby'
TYPES: list = ['competitive', 'custom']
MODES = {
TYPES[0]: {'modes': [1, 5], 'default': 5},
TYPES[1]: {'modes': [20], 'default': 20},
}
@property
def cache_key(self):
"""
The lobby key repr on Redis.
"""
return f'{self.Config.CACHE_PREFIX}:{self.owner_id}'
@property
def id(self):
"""
The lobby ID.
"""
return self.owner_id
@property
def players_ids(self) -> list:
"""
All player_ids that are in lobby. Owner included.
"""
return sorted(list(map(int, cache.smembers(f'{self.cache_key}:players'))))
@property
def non_owners_ids(self) -> list:
"""
All player_ids that are in lobby. Owner excluded.
"""
return sorted([id for id in self.players_ids if id != self.owner_id])
@property
def is_public(self) -> bool:
"""
Return wether lobby is public or private.
Public (1) -> any online user, with a valid account can join.
Private (0) -> only invited online users, with a valid account can join.
"""
return cache.get(f'{self.cache_key}:public') == '1'
@property
def invites(self) -> list:
"""
Retrieve all unaccepted invites.
"""
invite_ids = sorted(list(cache.zrange(f'{self.cache_key}:invites', 0, -1)))
return [
LobbyInvite.get(lobby_id=self.id, invite_id=invite_id)
for invite_id in invite_ids
]
@property
def invited_players_ids(self) -> list:
"""
Retrieve all invited player_id's.
"""
return sorted(list(map(int, [invite.to_id for invite in self.invites])))
@property
def queue(self) -> datetime:
"""
Return wether the lobby is queued.
"""
queue = cache.get(f'{self.cache_key}:queue')
if queue:
return str_to_timezone(queue)
@property
def queue_time(self) -> int:
"""
Get how much time, in seconds, lobby is queued.
"""
if self.queue:
return (timezone.now() - self.queue).seconds
@property
def players_count(self) -> int:
"""
Return how many players are in the lobby.
"""
return len(self.players_ids)
@property
def seats(self) -> int:
"""
Return how many seats are available for this lobby.
"""
return self.max_players - self.players_count
@property
def overall(self) -> int:
"""
The overall is the highest level among the players levels.
"""
return max(
list(
User.objects.filter(id__in=self.players_ids).values_list(
'account__level', flat=True
)
)
or [0]
)
@property
def lobby_type(self) -> str:
"""
Returns the lobby type which can be one of Config.TYPES.
"""
return cache.get(f'{self.cache_key}:type')
@property
def mode(self) -> int:
"""
Returns which competitive mode the lobby is on
from Config.COMPETITIVE_MODES.
"""
return int(cache.get(f'{self.cache_key}:mode'))
@property
def max_players(self) -> int:
"""
Returns how many players are allowed to take seat
on this lobby. The returned value is the same return of
the mode property.
"""
return self.mode
@property
def restriction_countdown(self) -> int:
"""
Return the greatest player restriction countdown in seconds.
"""
lock_countdowns = [
Player(user_id=player_id).lock_countdown
for player_id in self.players_ids
if Player(user_id=player_id).lock_countdown
]
if lock_countdowns:
return max(lock_countdowns)
@staticmethod
def get_current(player_id: int) -> Lobby:
"""
Get the current lobby the user is.
"""
lobby_id = cache.get(f'{Lobby.Config.CACHE_PREFIX}:{player_id}')
if not lobby_id:
return None
return Lobby(owner_id=lobby_id)
@staticmethod
def create(owner_id: int, lobby_type: str = None, mode: int = None) -> Lobby:
"""
Create a lobby for a given user.
"""
filter = User.objects.filter(pk=owner_id)
if not filter.exists():
raise LobbyException(_('User not found.'))
user = filter[0]
if not hasattr(user, 'account') or not user.account.is_verified:
raise LobbyException(
_('A verified account is required to perform this action.')
)
if not user.is_online:
raise LobbyException(_('Offline user.'))
if lobby_type is not None and lobby_type not in Lobby.Config.TYPES:
raise LobbyException(_('The given type is not valid.'))
if not lobby_type:
lobby_type = Lobby.Config.TYPES[0]
if mode is not None and mode not in Lobby.Config.MODES[lobby_type].get('modes'):
raise LobbyException(_('The given mode is not valid.'))
if not mode:
mode = Lobby.Config.MODES.get(lobby_type).get('default')
lobby = Lobby(owner_id=owner_id)
cache.set(lobby.cache_key, owner_id)
cache.set(f'{lobby.cache_key}:type', lobby_type)
cache.set(f'{lobby.cache_key}:mode', mode)
cache.sadd(f'{lobby.cache_key}:players', owner_id)
Player.create(user_id=owner_id)
return lobby
# flake8: noqa: C901
@staticmethod
def move(player_id: int, to_lobby_id: int, remove: bool = False) -> Lobby:
"""
This method move players around between lobbies.
If `to_lobby_id` == `player_id`, then this lobby
should be empty, meaning that we need to remove every
other player from it.
If `remove` is True, it means that we need to purge this
lobby, removing it from Redis cache. This usually happen
when the owner logs out. Before lobby deletion, we move
every other player (if there is any) to another lobby. We
also need to cancel the queue for the current lobby.
"""
from_lobby_id = cache.get(f'{Lobby.Config.CACHE_PREFIX}:{player_id}')
from_lobby = Lobby(owner_id=from_lobby_id)
to_lobby = Lobby(owner_id=to_lobby_id)
def transaction_pre(pipe):
if not pipe.get(from_lobby.cache_key) or not pipe.get(to_lobby.cache_key):
raise LobbyException(_('Lobby not found.'))
def transaction_operations(pipe, pre_result):
filter = User.objects.filter(pk=player_id)
new_lobby = None
if not filter.exists():
raise LobbyException(_('User not found.'))
if not remove:
joining_player = filter[0]
if (
not hasattr(joining_player, 'account')
or not joining_player.account.is_verified
):
raise LobbyException(
_('A verified account is required to perform this action.')
)
if not joining_player.is_online:
raise LobbyException(_('Offline user.'))
if from_lobby.queue or to_lobby.queue:
raise LobbyException(_('Lobby is queued.'))
is_owner = Lobby.is_owner(to_lobby_id, player_id)
can_join = (
to_lobby.is_public
or player_id in to_lobby.invited_players_ids
or is_owner
)
if not to_lobby.seats and to_lobby.owner_id != player_id:
raise LobbyException(_('Lobby is full.'))
if not can_join:
raise LobbyException(_('User must be invited.'))
if from_lobby_id != to_lobby_id:
pipe.srem(f'{from_lobby.cache_key}:players', player_id)
pipe.sadd(f'{to_lobby.cache_key}:players', player_id)
pipe.set(f'{Lobby.Config.CACHE_PREFIX}:{player_id}', to_lobby.owner_id)
invite = to_lobby.get_invite_by_to_player_id(player_id)
if invite:
pipe.zrem(f'{to_lobby.cache_key}:invites', invite.id)
if len(from_lobby.non_owners_ids) > 0 and from_lobby.owner_id == player_id:
new_owner_id = min(from_lobby.non_owners_ids)
new_lobby = Lobby(owner_id=new_owner_id)
pipe.srem(f'{from_lobby.cache_key}:players', *from_lobby.non_owners_ids)
# If another instance tries to move a player to the new_lobby,
# a transaction will start there and when we add a player here
# in the following line, the other transaction will fail and
# try again/rollback.
pipe.sadd(f'{new_lobby.cache_key}:players', *from_lobby.non_owners_ids)
for other_player_id in from_lobby.non_owners_ids:
pipe.set(
f'{Lobby.Config.CACHE_PREFIX}:{other_player_id}',
new_lobby.owner_id,
)
invite = new_lobby.get_invite_by_to_player_id(other_player_id)
if invite:
pipe.zrem(f'{new_lobby.cache_key}:invites', invite)
invites_from_player = from_lobby.get_invites_by_from_player_id(player_id)
for invite in invites_from_player:
pipe.zrem(f'{from_lobby.cache_key}:invites', invite.id)
if remove:
Lobby.delete(to_lobby.id, pipe=pipe)
from_lobby.cancel_queue()
return new_lobby
return cache.protected_handler(
transaction_operations,
f'{from_lobby.cache_key}:players',
f'{from_lobby.cache_key}:queue',
f'{to_lobby.cache_key}:players',
f'{to_lobby.cache_key}:queue',
f'{Lobby.Config.CACHE_PREFIX}:{player_id}',
f'{to_lobby.cache_key}:invites',
pre_func=transaction_pre,
value_from_callable=True,
)
def invite(self, from_player_id: int, to_player_id: int) -> LobbyInvite:
"""
Lobby players can invite others players to join them in the lobby following
these rules:
- Lobby should not be full;
- Player should not been invited yet;
- Invited user should exist, be online and have a verified account;
"""
if not self.seats:
raise LobbyException(_('Lobby is full.'))
if self.queue:
raise LobbyException(_('Lobby is queued.'))
def transaction_pre(pipe):
can_invite = pipe.sismember(f'{self.cache_key}:players', from_player_id)
already_player = pipe.sismember(f'{self.cache_key}:players', to_player_id)
already_invited = to_player_id in self.invited_players_ids
return (can_invite, already_player, already_invited)
def transaction_operations(pipe, pre_result):
can_invite, already_player, already_invited = pre_result
if not can_invite:
raise LobbyException(
'Usuário não tem permissão para realizar essa ação.'
)
if already_player:
raise LobbyException(_('Invited user is already a lobby player.'))
if already_invited:
raise LobbyException(_('User already invited.'))
filter = User.objects.filter(pk=to_player_id)
if not filter.exists():
raise LobbyException(_('User not found.'))
invited = filter[0]
if not hasattr(invited, 'account') or not invited.account.is_verified:
raise LobbyException(
_('A verified account is required to perform this action.')
)
if not invited.is_online:
raise LobbyException(_('Offline user.'))
pipe.zadd(
f'{self.cache_key}:invites',
{f'{from_player_id}:{to_player_id}': timezone.now().timestamp()},
)
cache.protected_handler(
transaction_operations,
f'{self.cache_key}:players',
f'{self.cache_key}:queue',
f'{self.cache_key}:invites',
pre_func=transaction_pre,
)
return LobbyInvite(
from_id=from_player_id, to_id=to_player_id, lobby_id=self.owner_id
)
def get_invite_by_to_player_id(self, to_player_id: int) -> str:
result = None
for invite in self.invites:
if to_player_id == invite.to_id:
result = invite
return result
def get_invites_by_from_player_id(self, from_player_id: int) -> str:
return [invite for invite in self.invites if from_player_id == invite.from_id]
def delete_invite(self, invite_id):
"""
Method to delete an existing invite
Invite should exist on lobby invites list
Should return False if the requested invite_id isn't in the lobby invites list
"""
invite = cache.zscore(f'{self.cache_key}:invites', invite_id)
if not invite:
raise LobbyException(_('Invite not found.'))
def transaction_operations(pipe, pre_result):
pipe.zrem(f'{self.cache_key}:invites', invite_id)
cache.protected_handler(transaction_operations, f'{self.cache_key}:invites')
def start_queue(self):
"""
Add lobby to the queue.
"""
if self.queue:
raise LobbyException(_('Lobby is queued.'))
for user_id in self.players_ids:
if Player.get_by_user_id(user_id=user_id).lock_date:
raise LobbyException(_('Can\'t start queue due to player restriction.'))
def transaction_operations(pipe, pre_result):
pipe.set(f'{self.cache_key}:queue', timezone.now().isoformat())
cache.protected_handler(
transaction_operations,
f'{self.cache_key}:players',
f'{self.cache_key}:queue',
f'{self.cache_key}:invites',
)
def cancel_queue(self):
"""
Remove lobby from queue.
"""
cache.delete(f'{self.cache_key}:queue')
def set_public(self):
"""
Change lobby privacy to public.
"""
if self.queue:
raise LobbyException(_('Lobby is queued.'))
cache.set(f'{self.cache_key}:public', 1)
def set_private(self):
"""
Change lobby privacy to private.
"""
if self.queue:
raise LobbyException(_('Lobby is queued.'))
cache.set(f'{self.cache_key}:public', 0)
def set_type(self, lobby_type: str):
"""
Sets the lobby type, which can be any value from Config.TYPES.
If no type is received or type isn't on Config.TYPES,
then defaults to Config.TYPES[0].
"""
if self.queue:
raise LobbyException(_('Lobby is queued.'))
if lobby_type not in self.Config.TYPES:
raise LobbyException(_('The given type is not valid.'))
cache.set(f'{self.cache_key}:type', lobby_type)
def set_mode(self, mode, players_id_to_remove=[]):
"""
Sets the lobby mode, which can be any value from Config.MODES.
If no mode is received or type isn't on Config.MODES,
then defaults to Config.COMP_DEFAULT_MODE.
"""
if self.queue:
raise LobbyException(_('Lobby is queued.'))
if mode not in self.Config.MODES[self.lobby_type].get('modes'):
raise LobbyException(_('The given mode is not valid.'))
players_ids = self.non_owners_ids
if self.players_count > mode:
if self.owner_id in players_id_to_remove:
raise LobbyException(_('Owner cannot be removed.'))
elif not players_id_to_remove:
for i in range(self.players_count - mode):
choice = random.choice(players_ids)
players_ids.remove(choice)
self.move(choice, choice)
else:
for player_id in players_id_to_remove:
players_ids.remove(player_id)
self.move(player_id, player_id)
cache.set(f'{self.cache_key}:mode', mode)
def get_min_max_overall_by_queue_time(self) -> tuple:
"""
Return the minimum and maximum lobby overall that this lobby
can team up or challenge.
"""
elapsed_time = int(self.queue_time)
if not self.queue or elapsed_time < 30:
min = self.overall - 1 if self.overall > 0 else 0
max = self.overall + 1
elif elapsed_time < 60:
min = self.overall - 2 if self.overall > 1 else 0
max = self.overall + 2
elif elapsed_time < 90:
min = self.overall - 3 if self.overall > 2 else 0
max = self.overall + 3
elif elapsed_time < 120:
min = self.overall - 4 if self.overall > 3 else 0
max = self.overall + 4
else:
min = self.overall - 5 if self.overall > 4 else 0
max = self.overall + 5
return min, max
@staticmethod
def is_owner(lobby_id: int, player_id: int) -> bool:
lobby = Lobby(owner_id=lobby_id)
return lobby.owner_id == player_id
@staticmethod
def delete(lobby_id: int, pipe=None):
lobby = Lobby(owner_id=lobby_id)
for player_id in lobby.players_ids:
Player.delete(player_id)
keys = cache.keys(f'{lobby.cache_key}:*')
if len(keys) >= 1:
if pipe:
pipe.delete(*keys)
pipe.delete(lobby.cache_key)
else:
cache.delete(*keys)
cache.delete(lobby.cache_key)
from __future__ import annotations
from typing import List
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext as _
from pydantic import BaseModel
from core.redis import RedisClient
from core.utils import str_to_timezone
cache = RedisClient()
User = get_user_model()
class PlayerException(Exception):
pass
class Player(BaseModel):
"""
This model represents players on Redis cache db.
The Redis db keys from this model are described below:
[set] __mm:players <user_id: int>
[zset] __mm:player:[user_id]:dodges <timezone: str, timezone>
[set] __mm:player:[user_id]:queue_lock <timezone: str>
"""
user_id: int
class Config:
CACHE_PREFIX: str = '__mm:player'
DODGES_EXPIRE_TIME = settings.PLAYER_DODGES_EXPIRE_TIME
DODGES_MULTIPLIER = [1, 5, 10, 15, 20, 40, 60, 90]
DODGES_MAX = len(DODGES_MULTIPLIER)
DODGES_MAX_TIME = 604800 # 1 week in minutes
@property
def cache_key(self) -> str:
"""
Model key repr on Redis.
"""
return f'{self.Config.CACHE_PREFIX}:{self.user_id}'
@property
def dodges(self) -> int:
"""
Return how many match dodges player has in last week.
"""
return cache.zcard(f'{self.cache_key}:dodges')
@property
def latest_dodge(self) -> timezone.datetime:
"""
Return the latest player match dodge.
"""
if self.dodges > 0:
return str_to_timezone(
cache.zrevrangebyscore(
f'{self.cache_key}:dodges', '+inf', '-inf', start=0, num=1
)[0]
)
@property
def lock_date(self) -> timezone.datetime:
"""
Return the timezone date when queue restriction will end,
if this player has a restriction.
"""
queue_lock = cache.get(f'{self.cache_key}:queue_lock')
if queue_lock:
return str_to_timezone(queue_lock)
@property
def lock_countdown(self) -> int:
"""
Return the countdown, in seconds, for the queue restriction end.
"""
if self.lock_date:
return (self.lock_date - timezone.now()).seconds
@staticmethod
def create(user_id: int) -> Player:
"""
Creates a player entry on Redis db and return a Player instance.
"""
cache.sadd('__mm:players', user_id)
return Player(user_id=user_id)
@staticmethod
def get_all() -> List[Player]:
"""
Fetches all players on Redis db.
"""
return [
Player(user_id=int(user_id)) for user_id in cache.smembers('__mm:players')
]
@staticmethod
def get_by_user_id(user_id: int) -> Player:
"""
Searches for a Player with the given user_id and returns it.
"""
if cache.sismember('__mm:players', user_id):
return Player(user_id=user_id)
raise PlayerException(_('Player not found'))
def dodge_add(self) -> timezone.datetime:
"""
Add a new dodge datetime on Redis db.
Each time a player dodges a match, the dodges set will receive a new entry, and its ttl
will be renewed.
When a player reaches 3 dodges in a week (Player.Config.DODGES_EXPIRE_TIME), this method
creates a queue_lock entry on Redis for that player. This entry holds the datetime when the
restriction ends. This entry restricts the player from queueing again on whatever lobby
he is until the restriction is over.
"""
if self.lock_date:
raise PlayerException(_('Player cannot dodge while in queue restriction.'))
cache.zadd(
f'{self.cache_key}:dodges',
{timezone.now().isoformat(): timezone.now().timestamp()},
)
cache.expire(f'{self.cache_key}:dodges', Player.Config.DODGES_EXPIRE_TIME)
if self.dodges >= Player.Config.DODGES_MAX:
delta = timezone.timedelta(minutes=Player.Config.DODGES_MAX_TIME)
lock_date = timezone.now() + delta
cache.set(
f'{self.cache_key}:queue_lock',
lock_date.isoformat(),
ex=delta,
)
return lock_date
elif self.dodges > 2:
multiplier = Player.Config.DODGES_MULTIPLIER[self.dodges - 2]
lock_minutes = self.dodges * multiplier
delta = timezone.timedelta(minutes=lock_minutes)
lock_date = timezone.now() + delta
cache.set(
f'{self.cache_key}:queue_lock',
lock_date.isoformat(),
ex=delta,
)
return lock_date
def dodge_clear(self):
"""
Clear all dodges from a player.
Should be called every week (7 days).
"""
cache.delete(f'{self.cache_key}:dodges')
@staticmethod
def delete(user_id: int):
"""
Delete player from the players set on Redis.
This should be called upon a player logout.
"""
player = Player.get_by_user_id(user_id=user_id)
cache.srem('__mm:players', player.user_id)
from __future__ import annotations
import random
import secrets
from math import ceil
from statistics import mean
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from pydantic import BaseModel
from core.redis import RedisClient
from lobbies.models import Lobby
cache = RedisClient()
User = get_user_model()
class TeamException(Exception):
"""
Custom Team exception class.
"""
pass
class TeamConfig:
"""
Config class for the Team model.
"""
CACHE_PREFIX: str = '__mm:team:'
ID_SIZE: int = 16
READY_PLAYERS_MIN: int = settings.TEAM_READY_PLAYERS_MIN
class Team(BaseModel):
"""
This model represents teams on Redis cache db.
Teams are full lobbies that are queued.
The Redis db keys from this model are described below:
[set] __mm:team:[team_id] <lobby_ids>
Stores a team.
[key] __mm:team:[team_id]:ready 1
If this key exists, the team is ready and able to find an
oposing team to play against. A team is ready when it reaches
the maximum numbers of players.
"""
id: str = None
cache_key: str = None
def __init__(self, **data):
super().__init__(**data)
self.id = self.id or secrets.token_urlsafe(TeamConfig.ID_SIZE)
self.cache_key = self.cache_key or f'{TeamConfig.CACHE_PREFIX}{self.id}'
@property
def lobbies_ids(self) -> list:
return sorted(list(map(int, cache.smembers(self.cache_key))))
@property
def players_count(self) -> int:
"""
Return how many players are in all the team lobbies.
"""
return sum(
[Lobby(owner_id=lobby_id).players_count for lobby_id in self.lobbies_ids]
)
@property
def ready(self) -> bool:
"""
Return whether this team is ready to find a oposing team.
"""
return self.players_count == TeamConfig.READY_PLAYERS_MIN
@property
def overall(self) -> int:
"""
Return all lobbies overall.
If there is only one lobby on team, then the overral will
be the highest level among that lobby players (what is equal to lobby.overall).
If there is more then one lobby, the overall will be the average between all lobbies.
This is effective because if there is only one lobby, it means that is a pre builded lobby
with friends, and thus we want to pair by the highest skilled/leveled player.
"""
return ceil(
mean([Lobby(owner_id=lobby_id).overall for lobby_id in self.lobbies_ids])
)
@property
def min_max_overall_by_queue_time(self) -> tuple:
"""
Return the minimum and maximum team overall that this team
can team up or challenge.
"""
try:
elapsed_time = ceil(mean([lobby.queue_time for lobby in self.lobbies]))
except:
raise TeamException(_('Lobbies must be queued to calculate their overall.'))
if elapsed_time < 30:
min = self.overall - 1 if self.overall > 0 else 0
max = self.overall + 1
elif elapsed_time < 60:
min = self.overall - 2 if self.overall > 1 else 0
max = self.overall + 2
elif elapsed_time < 90:
min = self.overall - 3 if self.overall > 2 else 0
max = self.overall + 3
elif elapsed_time < 120:
min = self.overall - 4 if self.overall > 3 else 0
max = self.overall + 4
else:
min = self.overall - 5 if self.overall > 4 else 0
max = self.overall + 5
return min, max
@property
def lobbies(self) -> list[Lobby]:
"""
Return lobbies.
"""
return [Lobby(owner_id=lobby_id) for lobby_id in self.lobbies_ids]
@property
def type_mode(self) -> tuple:
"""
Return team type and mode.
"""
return self.lobbies[0].lobby_type, self.lobbies[0].mode
@property
def name(self) -> str:
"""
Return team name defined randomly between owners in lobbies
"""
owners_ids = [lobby.owner_id for lobby in self.lobbies]
owner_chosen_id = random.choice(owners_ids)
return User.objects.get(pk=owner_chosen_id).steam_user.username
@staticmethod
def overall_match(team, lobby) -> bool:
min_overall, max_overall = team.min_max_overall_by_queue_time
return min_overall <= lobby.overall <= max_overall
@staticmethod
def get_all() -> list[Team]:
"""
Fetch and return all Teams on Redis db.
"""
teams_keys = cache.keys(f'{TeamConfig.CACHE_PREFIX}*')
return [Team.get_by_id(team_key.split(':')[2]) for team_key in teams_keys]
@staticmethod
def get_all_not_ready() -> list[Team]:
"""
Fetch all non ready teams in Redis db.
"""
teams = Team.get_all()
return [team for team in teams if not team.ready]
@staticmethod
def get_all_ready() -> list[Team]:
"""
Fetch all ready teams in Redis db.
"""
teams = Team.get_all()
return [team for team in teams if team.ready]
@staticmethod
def get_by_lobby_id(lobby_id: int, fail_silently=False) -> Team:
"""
Searchs for a team given a lobby id.
"""
team = next(
(team for team in Team.get_all() if lobby_id in team.lobbies_ids), None
)
if not team and not fail_silently:
raise TeamException(_('Team not found.'))
return team
@staticmethod
def get_by_id(id: str) -> Team:
"""
Searchs for a team given an id.
"""
cache_key = f'{TeamConfig.CACHE_PREFIX}{id}'
result = cache.smembers(cache_key)
if not result:
raise TeamException(_('Team not found.'))
return Team(id=id)
@staticmethod
def create(lobbies_ids: list) -> Team:
"""
Create a Team in Redis cache db given a list of lobbies ids.
"""
players_count = sum(
[Lobby(owner_id=lobby_id).players_count for lobby_id in lobbies_ids]
)
if players_count > TeamConfig.READY_PLAYERS_MIN:
raise TeamException(_('Team players count exceeded.'))
team_id = secrets.token_urlsafe(TeamConfig.ID_SIZE)
cache.sadd(f'{TeamConfig.CACHE_PREFIX}{team_id}', *lobbies_ids)
return Team.get_by_id(team_id)
@staticmethod
def find(lobby: Lobby) -> Team:
"""
Find a team for the given lobby.
"""
# check if received lobby already is on a team
teams = Team.get_all()
if any(lobby.id in team.lobbies_ids for team in teams):
raise TeamException(_('Lobby already on a team.'))
# check whether the lobby is queued
if not lobby.queue:
raise TeamException(_('Lobby not queued.'))
not_ready = Team.get_all_not_ready()
for team in not_ready:
if team.players_count + lobby.players_count <= lobby.max_players:
# check if lobby and team type/mode matches
if team.type_mode == (lobby.lobby_type, lobby.mode):
if Team.overall_match(team, lobby):
team.add_lobby(lobby.id)
return team
@staticmethod
def build(lobby: Lobby) -> Team:
"""
Look for queued lobbies that are compatible
and put them together in a team.
"""
# check if received lobby already is on a team
teams = Team.get_all()
if any(lobby.id in team.lobbies_ids for team in teams):
raise TeamException(_('Lobby already on a team.'))
# check whether the lobby is queued
if not lobby.queue:
raise TeamException(_('Lobby not queued.'))
team = Team.create(lobbies_ids=[lobby.id])
# check if team is full already
if team.players_count == TeamConfig.READY_PLAYERS_MIN:
return team
# get all queued lobbies
lobby_ids = [
int(key.split(':')[2])
for key in cache.keys('__mm:lobby:*:queue')
if lobby.id != int(key.split(':')[2])
]
for lobby_id in lobby_ids:
other_lobby = Lobby(owner_id=lobby_id)
# check if lobbies type and mode matches
if (
lobby.lobby_type == other_lobby.lobby_type
and lobby.mode == other_lobby.mode
):
# check if lobbies have seats enough to merge
total_players = team.players_count + other_lobby.players_count
if total_players <= lobby.max_players:
# check if lobbies are in the same overall range
min_overall, max_overall = lobby.get_min_max_overall_by_queue_time()
if min_overall <= other_lobby.overall <= max_overall:
team.add_lobby(other_lobby.id)
if len(team.lobbies_ids) > 1:
return team
else:
team.delete()
return None
def delete(self):
"""
Delete team from Redis db.
"""
cache.delete(self.cache_key)
def add_lobby(self, lobby_id: int):
"""
Add a lobby into a Team on Redis db.
"""
lobby = Lobby(owner_id=lobby_id)
def transaction_operations(pipe, pre_result):
pipe.sadd(self.cache_key, lobby_id)
cache.protected_handler(
transaction_operations,
f'{lobby.cache_key}:players',
f'{lobby.cache_key}:queue',
)
def remove_lobby(self, lobby_id: int):
"""
Remove a lobby from a Team on Redis db.
If that team was ready, then it becomes not ready.
"""
cache.srem(self.cache_key, lobby_id)
if len(self.lobbies_ids) <= 1:
self.delete()
def get_opponent_team(self):
ready_teams = self.get_all_ready()
for team in ready_teams:
if self.id != team.id:
# check if type and mode matches
if self.type_mode == team.type_mode:
# check if teams are in the same overall range
min_overall, max_overall = team.min_max_overall_by_queue_time
if min_overall <= self.overall <= max_overall:
return team
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment