WebSockets

PublicQueueConsumer

Bases: AsyncWebsocketConsumer

WebSocket consumer for the public matchmaking queue.

Handles automatic matchmaking: players connect, wait until enough players are in the queue, and are then redirected to a newly created game.


How to Connect

Endpoint: ws://<host>/ws/queue/

Authentication required: Yes — the user must be authenticated via session or token before connecting. Unauthenticated connections are closed with code 4002.


Connection Lifecycle

  1. Client opens the WebSocket.
  2. Server adds the user to the matchmaking queue.
  3. If enough players are queued (NUM_PUBLIC_GAME_PLAYERS), the server automatically creates a game and sends a match_found event to every matched player.
  4. Each matched client receives the game_id and should navigate to the game screen, then connect to GameConsumer.

Messages: Client → Server

Cancel Queue

Voluntarily leave the matchmaking queue.

{ "action": "cancel" }

Messages: Server → Client

Match Found

Sent when matchmaking succeeds. The connection is closed with code 4001 immediately after this message.

{
    "action": "match_found",
    "game_id": 42
}
Error

Sent when an invalid message is received.

{
    "action": "error",
    "message": "Datos invalidos."
}

Close Codes

Code Meaning
4001 Match found — user was moved to a game. Connect to GameConsumer.
4002 Unauthorized — user was not authenticated.
4000 User cancelled the queue.

Example Flow (JavaScript)

const socket = new WebSocket("ws://localhost:8000/ws/queue/");

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.action === "match_found") {
        // Navigate to game with data.game_id
        connectToGame(data.game_id);
    }
};

// To leave the queue manually:
socket.send(JSON.stringify({ action: "cancel" }));
Source code in magnate/consumers.py
class PublicQueueConsumer(AsyncWebsocketConsumer):
    """
    WebSocket consumer for the public matchmaking queue.

    Handles automatic matchmaking: players connect, wait until enough players
    are in the queue, and are then redirected to a newly created game.

    ---
    ## How to Connect

    **Endpoint:** ``ws://<host>/ws/queue/``

    **Authentication required:** Yes — the user must be authenticated via session
    or token before connecting. Unauthenticated connections are closed with code ``4002``.

    ---
    ## Connection Lifecycle

    1. Client opens the WebSocket.
    2. Server adds the user to the matchmaking queue.
    3. If enough players are queued (``NUM_PUBLIC_GAME_PLAYERS``), the server
       automatically creates a game and sends a ``match_found`` event to every
       matched player.
    4. Each matched client receives the ``game_id`` and should navigate to the
       game screen, then connect to ``GameConsumer``.

    ---
    ## Messages: Client → Server

    ### Cancel Queue
    Voluntarily leave the matchmaking queue.

    ```json
    { "action": "cancel" }
    ```

    ---
    ## Messages: Server → Client

    ### Match Found
    Sent when matchmaking succeeds. The connection is closed with code ``4001``
    immediately after this message.

    ```json
    {
        "action": "match_found",
        "game_id": 42
    }
    ```

    ### Error
    Sent when an invalid message is received.

    ```json
    {
        "action": "error",
        "message": "Datos invalidos."
    }
    ```

    ---
    ## Close Codes

    | Code | Meaning |
    |------|---------|
    | 4001 | Match found — user was moved to a game. Connect to ``GameConsumer``. |
    | 4002 | Unauthorized — user was not authenticated. |
    | 4000 | User cancelled the queue. |

    ---
    ## Example Flow (JavaScript)

    ```js
    const socket = new WebSocket("ws://localhost:8000/ws/queue/");

    socket.onmessage = (event) => {
        const data = JSON.parse(event.data);
        if (data.action === "match_found") {
            // Navigate to game with data.game_id
            connectToGame(data.game_id);
        }
    };

    // To leave the queue manually:
    socket.send(JSON.stringify({ action: "cancel" }));
    ```
    """

    QUEUE_GROUP = "public_queue"

    async def connect(self):
        scope_user = self.scope.get('user')
        if scope_user is None or getattr(scope_user, 'is_anonymous', True):
            await self.close(code=4002)
            return

        self.user = await database_sync_to_async(CustomUser.objects.get)(pk=scope_user.pk)
        await self.accept()

        # join queue
        await self.channel_layer.group_add(self.QUEUE_GROUP, self.channel_name)

        await self.add_user_to_queue(self.user, self.channel_name)

        # checking
        match_result = await self.matchmaking_logic()

        if match_result:    
            game_id, players_channels = match_result
            for channel in players_channels:
                await self.channel_layer.send(
                    channel,
                    {
                        'type': 'match_found_event',
                        'game_id': game_id
                    }
                )

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.QUEUE_GROUP, self.channel_name)

        if close_code == 4001:
            print(f"User {self.user} found a match and is leaving the queue.")
        elif close_code == 4002:
            return 
        else:
            print(f"User {self.user} left the queue.")
            await self.remove_user_from_queue(self.user)

    async def receive(self, text_data):
        try:
            data = json.loads(text_data)
            if data.get('action') == 'cancel':
                await self.close(code=4000)
        except json.JSONDecodeError:
            await self.send_error("Datos invalidos.")

    async def match_found_event(self, event):
        await self.send(text_data=json.dumps({
            'action': 'match_found',
            'game_id': event['game_id']
        }))
        await self.close(code=4001)

    async def send_error(self, message):
        await self.send(text_data=json.dumps({
            'action': 'error',
            'message': message
        }))


# ---------------- DB access methods --------------#
    @database_sync_to_async
    def add_user_to_queue(self, user, channel_name):
        existing_position = PublicQueuePosition.objects.filter(user=user).first()
        if existing_position:
            # User already in queue, don't add again
            return None

        # Create queue position for the user
        PublicQueuePosition.objects.create(
            user=user,
            channel = channel_name,
            date_time=timezone.now()

        )


    @database_sync_to_async
    def remove_user_from_queue(self, user):
        existing_position =  PublicQueuePosition.objects.filter(user=user).first()
        if existing_position:
            existing_position.delete()
        else:
            # User not in queue
            return None


    # TODO: be aware of race conditions -> new users while executing the method
    @database_sync_to_async
    def matchmaking_logic(self):
        with transaction.atomic():

            players = list(PublicQueuePosition.objects.select_for_update().order_by('date_time')[:NUM_PUBLIC_GAME_PLAYERS])

            if len(players) < NUM_PUBLIC_GAME_PLAYERS: 
                return None

            player_channels = [player.channel for player in players]
            users = [player.user for player in players]

            # create with compulsory sh -> TODO: change to random
            game = Game.objects.create(
                datetime=timezone.now(),
                active_turn_player=users[0],  
                active_phase_player=users[0],
                phase=GameManager.ROLL_THE_DICES
            )

            # initialize money and positions (see then what the optimal money)
            game.money = {str(u.pk): 1500 for u in users}
            game.positions = {str(u.pk): "000" for u in users}


            game.players.set(users)
            game.ordered_players = [u.pk for u in users]
            game.ordered_players = random.sample(game.ordered_players, len(game.ordered_players)) #random order of players


            task = kick_out_callback.apply_async(args=[game.pk, users[0].pk], countdown=50) #necessary for first turn
            game.kick_out_task_id = task.id
            game.save()


            for user in users:
                user.active_game = game
                user.played_games.add(game)
                user.save()

            PublicQueuePosition.objects.filter(pk__in=[p.pk for p in players]).delete()

            return (game.pk, player_channels)

PrivateRoomConsumer

Bases: AsyncWebsocketConsumer

WebSocket consumer for private game room lobbies.

Manages the pre-game lobby where a host invites friends, players set their ready status, and the host starts the game when all players are ready. Supports bot-filling up to the configured target player count.


How to Connect

Endpoint: ws://<host>/ws/room/<room_code>/

Authentication required: Yes. Unauthenticated users are rejected with code 4002.

The room_code must correspond to an existing PrivateRoom. Attempting to join a non-existent or full room will result in an error message followed by close code 4003.


Connection Lifecycle

  1. Client opens the WebSocket with a valid room_code.
  2. Server validates room existence, capacity, and that the user is not already in the room.
  3. On success, the server broadcasts a joined lobby update to all members.
  4. Players toggle ready status. The host can change settings (bot level, target player count).
  5. When all players are ready, the host sends start_game.
  6. Server creates the game (filling remaining slots with bots if needed) and broadcasts a game_start event to everyone.
  7. Each client receives the game_id and connects to GameConsumer.

Messages: Client → Server

All messages are JSON objects with a command field.

Toggle Ready Status
{ "command": "ready_status", "is_ready": true }
Start Game (host only)

Requires all players to be ready and at least MIN_PRIVATE_GAME_PLAYERS in the room. Bots are added to reach target_players if needed.

{ "command": "start_game" }
Send Chat Message
{ "command": "chat_message", "message": "Hello!" }
Update Room Settings (host only)

Change the target number of players and/or the bot difficulty level.

{
    "command": "update_settings",
    "bot_level": "easy",
    "target_players": 4
}

Messages: Server → Client

Player Joined

Broadcast to all members when someone connects.

{
    "action": "joined",
    "user": "alice",
    "owner": "alice",
    "is_owner": true,
    "players": [
        { "username": "alice", "ready_to_play": false }
    ]
}
Player Left

Broadcast to remaining members when someone disconnects. owner may change if the previous owner left (host migration).

{
    "action": "player_left",
    "user_left": "bob",
    "owner": "alice",
    "is_owner": true,
    "players": [
        { "username": "alice", "ready_to_play": false }
    ]
}
Ready Status Update

Broadcast to all members when any player changes their ready status.

{
    "action": "ready_status",
    "user": "bob",
    "is_ready": true,
    "owner": "alice",
    "is_owner": false
}
Settings Changed

Broadcast to all members when the host updates room settings.

{
    "action": "settings_changed",
    "bot_level": "hard",
    "target_players": 4
}
Game Start

Broadcast to all members when the game has been created. The connection is closed with code 4001 immediately after.

{
    "action": "game_start",
    "game_id": 42
}
Chat Message

Broadcast to all members.

{
    "action": "chat_message",
    "user": "alice",
    "message": "Good luck!"
}
Error

Sent only to the client that triggered the error.

{
    "action": "error",
    "message": "Solo el host puede iniciar una partida."
}

Close Codes

Code Meaning
4001 Game started — connect to GameConsumer with the received game_id.
4002 Unauthorized.
4003 Room not found, full, or user already in room.

Notes

  • is_owner in lobby events is computed per-recipient: true only for the current room host.
  • If the host disconnects, ownership is automatically transferred to the next oldest player.
  • If the last player leaves, the room is deleted.
  • Bots are created at game start to fill slots up to target_players; they are not visible in the lobby.

Example Flow (JavaScript)

const socket = new WebSocket("ws://localhost:8000/ws/room/ABC123/");

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);

    switch (data.action) {
        case "joined":
        case "player_left":
            updatePlayerList(data.players, data.owner);
            break;
        case "ready_status":
            updateReadyIndicator(data.user, data.is_ready);
            break;
        case "settings_changed":
            updateSettings(data.bot_level, data.target_players);
            break;
        case "game_start":
            connectToGame(data.game_id);
            break;
        case "error":
            showError(data.message);
            break;
    }
};

// Mark yourself as ready:
socket.send(JSON.stringify({ command: "ready_status", is_ready: true }));

// Host starts the game:
socket.send(JSON.stringify({ command: "start_game" }));
Source code in magnate/consumers.py
class PrivateRoomConsumer(AsyncWebsocketConsumer):
    """
    WebSocket consumer for private game room lobbies.

    Manages the pre-game lobby where a host invites friends, players set their
    ready status, and the host starts the game when all players are ready. Supports
    bot-filling up to the configured target player count.

    ---
    ## How to Connect

    **Endpoint:** ``ws://<host>/ws/room/<room_code>/``

    **Authentication required:** Yes. Unauthenticated users are rejected with code ``4002``.

    The ``room_code`` must correspond to an existing ``PrivateRoom``. Attempting to
    join a non-existent or full room will result in an error message followed by
    close code ``4003``.

    ---
    ## Connection Lifecycle

    1. Client opens the WebSocket with a valid ``room_code``.
    2. Server validates room existence, capacity, and that the user is not already in
       the room.
    3. On success, the server broadcasts a ``joined`` lobby update to all members.
    4. Players toggle ready status. The host can change settings (bot level, target
       player count).
    5. When all players are ready, the host sends ``start_game``.
    6. Server creates the game (filling remaining slots with bots if needed) and
       broadcasts a ``game_start`` event to everyone.
    7. Each client receives the ``game_id`` and connects to ``GameConsumer``.

    ---
    ## Messages: Client → Server

    All messages are JSON objects with a ``command`` field.

    ### Toggle Ready Status
    ```json
    { "command": "ready_status", "is_ready": true }
    ```

    ### Start Game *(host only)*
    Requires all players to be ready and at least ``MIN_PRIVATE_GAME_PLAYERS`` in
    the room. Bots are added to reach ``target_players`` if needed.
    ```json
    { "command": "start_game" }
    ```

    ### Send Chat Message
    ```json
    { "command": "chat_message", "message": "Hello!" }
    ```

    ### Update Room Settings *(host only)*
    Change the target number of players and/or the bot difficulty level.
    ```json
    {
        "command": "update_settings",
        "bot_level": "easy",
        "target_players": 4
    }
    ```

    ---
    ## Messages: Server → Client

    ### Player Joined
    Broadcast to all members when someone connects.
    ```json
    {
        "action": "joined",
        "user": "alice",
        "owner": "alice",
        "is_owner": true,
        "players": [
            { "username": "alice", "ready_to_play": false }
        ]
    }
    ```

    ### Player Left
    Broadcast to remaining members when someone disconnects. ``owner`` may change
    if the previous owner left (host migration).
    ```json
    {
        "action": "player_left",
        "user_left": "bob",
        "owner": "alice",
        "is_owner": true,
        "players": [
            { "username": "alice", "ready_to_play": false }
        ]
    }
    ```

    ### Ready Status Update
    Broadcast to all members when any player changes their ready status.
    ```json
    {
        "action": "ready_status",
        "user": "bob",
        "is_ready": true,
        "owner": "alice",
        "is_owner": false
    }
    ```

    ### Settings Changed
    Broadcast to all members when the host updates room settings.
    ```json
    {
        "action": "settings_changed",
        "bot_level": "hard",
        "target_players": 4
    }
    ```

    ### Game Start
    Broadcast to all members when the game has been created. The connection is
    closed with code ``4001`` immediately after.
    ```json
    {
        "action": "game_start",
        "game_id": 42
    }
    ```

    ### Chat Message
    Broadcast to all members.
    ```json
    {
        "action": "chat_message",
        "user": "alice",
        "message": "Good luck!"
    }
    ```

    ### Error
    Sent only to the client that triggered the error.
    ```json
    {
        "action": "error",
        "message": "Solo el host puede iniciar una partida."
    }
    ```

    ---
    ## Close Codes

    | Code | Meaning |
    |------|---------|
    | 4001 | Game started — connect to ``GameConsumer`` with the received ``game_id``. |
    | 4002 | Unauthorized. |
    | 4003 | Room not found, full, or user already in room. |

    ---
    ## Notes

    - ``is_owner`` in lobby events is computed per-recipient: ``true`` only for the
      current room host.
    - If the host disconnects, ownership is automatically transferred to the next
      oldest player.
    - If the last player leaves, the room is deleted.
    - Bots are created at game start to fill slots up to ``target_players``; they
      are not visible in the lobby.

    ---
    ## Example Flow (JavaScript)

    ```js
    const socket = new WebSocket("ws://localhost:8000/ws/room/ABC123/");

    socket.onmessage = (event) => {
        const data = JSON.parse(event.data);

        switch (data.action) {
            case "joined":
            case "player_left":
                updatePlayerList(data.players, data.owner);
                break;
            case "ready_status":
                updateReadyIndicator(data.user, data.is_ready);
                break;
            case "settings_changed":
                updateSettings(data.bot_level, data.target_players);
                break;
            case "game_start":
                connectToGame(data.game_id);
                break;
            case "error":
                showError(data.message);
                break;
        }
    };

    // Mark yourself as ready:
    socket.send(JSON.stringify({ command: "ready_status", is_ready: true }));

    // Host starts the game:
    socket.send(JSON.stringify({ command: "start_game" }));
    ```
    """
    async def connect(self):
        # Triggered when user opens a new private room or joins an existing one.
        scope_user = self.scope.get('user')
        if scope_user is None or getattr(scope_user, 'is_anonymous', True):
            await self.close(code=4002)
            return

        self.user = await database_sync_to_async(CustomUser.objects.get)(pk=scope_user.pk)

        self.url = self.scope.get('url_route')
        if self.url is None:
            await self.close(code=4002)
            return

        self.room_code = self.url.get('kwargs').get('room_code')

        self.room_group_name = f"lobby_{self.room_code}"

        can_join, message = await self.check_room(self.room_code)

        # Case of invalid code or full lobby -> reject connection
        if not can_join:
            # Acept to send error message and close connection
            await self.accept()
            await self.send(text_data=json.dumps({'error': message}))
            await self.close(code=4003) 
            return

        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

        players = await self.join_room_group_db(self.room_code, self.user)
        if not players:
            await self.close(code=4003)
            return

        # Notify lobby members of new player and update player list
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'lobby_update',
                'action': 'joined',
                'user': self.user.username,
                'players': players,
                'owner': players[0]['username']
            }
        )

    async def disconnect(self, close_code):
        # Triggered when user leaves the private room lobby.
        # If owner leaves -> change host to the second older player. Else -> just update lobby.
        if close_code == 4002:
            print(f"Unauthorized user attempted to connect and was rejected.")
            return

        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

        # DB operations -> if needed, rotate host, remove room if empty, etc. Return updated player list and new host if needed.
        room_data = await self.leave_room_and_update_host(self.room_code, self.user)

        if not room_data:
            return

        if self.user is None:
            return


        if room_data:
            await self.channel_layer.group_send(
                self.room_group_name,
                {
                    'type': 'lobby_update',
                    'action': 'player_left',
                    'user_left': self.user.username,
                    'owner': room_data["owner"],
                    'players': room_data['players'] 
                }
            )       


    async def receive(self, text_data):
        # Chat messages or 'start_game' command /'ready' status updates.
        if self.user is None:
            return

        try:
            data = json.loads(text_data)
            command = data.get('command')

            if not command:
                await self.send_error("Comando invalido.")
                return


            if command == 'start_game':
                is_owner = await self.is_owner(self.user, self.room_code)
                if not is_owner:
                        await self.send_error("Solo el host puede iniciar una partida.")
                        return
                num_players = await self.get_num_players(self.room_code)

                if num_players < MIN_PRIVATE_GAME_PLAYERS:
                    await self.send_error(f"Se necesitan {MIN_PRIVATE_GAME_PLAYERS} jugadores para iniciar la partida.")
                    return

                all_ready = await self.check_all_ready(self.room_code)
                if not all_ready:
                    await self.send_error("Todos los jugadores deben estar listos para iniciar la partida.")
                    return

                game_pk = await self.create_private_game(self.room_code)

                await self.channel_layer.group_send(
                    self.room_group_name,
                    {
                        'type': 'game_start',
                        'game_id': game_pk
                    })



            elif command == 'ready_status':
                is_ready = data.get('is_ready')

                # Update in db 
                await self.update_player_ready_status(self.room_code, self.user, is_ready)

                owner = await self.get_room_owner(self.room_code)


                await self.channel_layer.group_send(
                    self.room_group_name,
                    {
                        'type': 'lobby_update',
                        'action': 'ready_status',
                        'user': self.user.username,
                        'is_ready': is_ready, 
                        'owner': owner
                    }
                )


            elif command == 'chat_message':
                message = data.get('message')
                await self.channel_layer.group_send(
                            self.room_group_name,
                            {
                                'type': 'chat_event',
                                'user': self.user.username,
                                'message': message
                            }
                        )

            elif command == 'update_settings': # owner changes target users and bot level
                is_owner = await self.is_owner(self.user, self.room_code)
                if not is_owner:
                    await self.send_error("Solo el host puede cambiar la configuración.")
                    return

                bot_level = data.get('bot_level')
                target_players = data.get('target_players')

                await self.update_room_settings(self.room_code, bot_level, target_players)

                # Avisar a todos los de la sala del cambio
                await self.channel_layer.group_send(
                    self.room_group_name,
                    {
                        'type': 'lobby_update',
                        'action': 'settings_changed',
                        'bot_level': bot_level,
                        'target_players': target_players
                    }
                )

        except json.JSONDecodeError:
            await self.send_error("Datos invalidos.")
            return

# -------------------- Handlers  -------------------- #
    async def lobby_update(self, event):
        if self.user is None:
            return

        # Send lobby updates to frontend (new player joined, player left)
        if event['action'] == 'joined':
            await self.send(text_data=json.dumps({
                'action': event['action'],
                'user': event['user'],
                'owner': event['owner'],
                'is_owner': (self.user.username == event['owner']),
                'players': event['players']
            }))
        elif event['action'] == 'player_left':
            await self.send(text_data=json.dumps({
                'action': event['action'],
                'user_left': event['user_left'],
                'owner': event['owner'],
                'is_owner': (self.user.username == event['owner']),
                'players': event['players']
            }))
        elif event['action'] == 'ready_status':
            await self.send(text_data=json.dumps({
                'action': event['action'],
                'user': event['user'],
                'is_ready': event['is_ready'], 
                'owner': event['owner'], 
                'is_owner': (self.user.username == event['owner'])
            }))
        elif event['action'] == 'settings_changed':
            await self.send(text_data=json.dumps({
                'action': event['action'],
                'bot_level': event['bot_level'],
                'target_players': event['target_players']
            }))


    @database_sync_to_async
    def update_room_settings(self, room_code, bot_level, target_players):
        room = PrivateRoom.objects.get(room_code=room_code)
        if bot_level: 
            room.bot_level = bot_level
        if target_players: 
            room.target_players = target_players
        room.save()

    @database_sync_to_async
    def create_private_game(self, room_code):
        import random
        from django.utils import timezone
        from .models import PrivateRoom, Game, CustomUser
        from .games import GameManager


        room = PrivateRoom.objects.get(room_code=room_code)
        real_users = list(room.players.all())
        users = real_users.copy()

        # fill with bots
        huecos = room.target_players - len(real_users)
        for i in range(huecos):
            # Generar un nombre único para la partida
            bot_username = f"Bot_{room_code}_{i+1}" 
            bot_user, _ = CustomUser.objects.get_or_create(
                username=bot_username,
                defaults={'email': f"{bot_username}@magnate.com", 'is_bot': True } #change level
            )

            bot_user.bot_level = room.bot_level
            bot_user.save()

            users.append(bot_user)

        game = Game.objects.create(
            datetime=timezone.now(),
            active_turn_player=users[0], # Se ajusta abajo
            active_phase_player=users[0],
            phase=GameManager.ROLL_THE_DICES
        )

        game.money = {str(u.pk): 1500 for u in users}
        game.positions = {str(u.pk): "000" for u in users}

        game.players.set(users)
        ordered_pks = [u.pk for u in users]
        random.shuffle(ordered_pks)
        game.ordered_players = ordered_pks

        first_player = CustomUser.objects.get(pk=ordered_pks[0])
        game.active_turn_player = first_player
        game.active_phase_player = first_player

        for user in users:
            user.active_game = game
            user.played_games.add(game)
            user.current_private_room = None 

            user.save()
            PlayerGameStatistic.objects.get_or_create(user=user, game=game)

        room.delete()

        GameManager._set_kick_out_timer(game, first_player)
        if first_player.is_bot:
            from .tasks import bot_play_callback
            bot_play_callback.apply_async(args=[game.pk, first_player.pk], countdown=5) # wait 5 for front to charge -> check this time with the boys

        game.save()



        return game.pk



    async def chat_event(self, event):
        # Send chat messages to frontend
        await self.send(text_data=json.dumps({
            'action': 'chat_message',
            'user': event['user'],
            'message': event['message']
        }))

    async def game_start(self, event):
        await self.send(text_data=json.dumps({
            'action': 'game_start',
            'game_id': event['game_id']
        }))
        await self.close(code=4001)



# ------------------- DB access methos -------------------- #
    @database_sync_to_async
    def check_room(self, room_code):
        if self.user is None:
            return False, None

        # Check if room exists and if user can join (not full, not already in, etc). Create if doesn't exist and user is creating it.
        room  = PrivateRoom.objects.filter(room_code=room_code).first()

        if not room:
            return False, "Sala no encontrada."



        #Using relation players in users - private room
        current_number_players =  room.players.count()

        if current_number_players >= MAX_PRIVATE_GAME_PLAYERS:
            return False, "Sala llena."

        user = CustomUser.objects.get(username=self.user.username)
        current_private_room = user.current_private_room

        if current_private_room== room:
            return False, "Ya estás en esta sala."

        return True, None



    @database_sync_to_async
    def join_room_group_db(self, room_code, user):
        current_user = CustomUser.objects.get(username=user.username)
        room = PrivateRoom.objects.get(room_code=room_code)

        current_user.current_private_room = PrivateRoom.objects.get(room_code=room_code)
        current_user.ready_to_play = False
        current_user.save()

        return list(room.players.values('username', 'ready_to_play'))

    @database_sync_to_async
    def leave_room_and_update_host(self, room_code, user):
        room = PrivateRoom.objects.filter(room_code=room_code).first()
        if not room:
            return None
        user_from_db = CustomUser.objects.get(username=user.username)

        user_from_db.current_private_room = None
        user_from_db.save()

        new_owner = room.players.exclude(pk=user_from_db.pk).first()

        # if no one left, delete
        if new_owner is None:
            room.delete()
            return None

        room.owner = new_owner
        room.save()

        return {
            'owner': room.owner.username,
            'players': list(room.players.values('username', 'ready_to_play'))
        }

    @database_sync_to_async
    def update_player_ready_status(self, room_code, user, is_ready):
        user_from_db = CustomUser.objects.get(username=user.username)

        if user_from_db.current_private_room is None:
            return None

        if user_from_db.current_private_room.room_code != room_code:
            return False

        user_from_db.ready_to_play = is_ready
        user_from_db.save()

    @database_sync_to_async
    def get_num_players(self, room_code):
        # Return the current number of players in the room
        room = PrivateRoom.objects.get(room_code=room_code)
        if not room:
            return 0
        return room.players.count()

    @database_sync_to_async
    def check_all_ready(self, room_code):
        # Check if all players in the room are ready
        room = PrivateRoom.objects.get(room_code=room_code)
        if not room:
            return False

        for player in room.players.all():
            if not player.ready_to_play:
                return False

        return True

    @database_sync_to_async
    def is_owner(self, user, room_code):
        # Check if the user is the host of the room
        room = PrivateRoom.objects.get(room_code=room_code)
        if not room:
            return False

        return room.owner == user

    @database_sync_to_async
    def get_room_owner(self, room_code):
        # Return the username of the room's owner
        room = PrivateRoom.objects.get(room_code=room_code)
        if not room:
            return False

        if room.owner is None:
            return None

        return room.owner.username

    async def send_error(self, message):
        await self.send(text_data=json.dumps({
            'action': 'error',
            'message': message
        }))

GameConsumer

Bases: AsyncWebsocketConsumer

WebSocket consumer for an active game session.

This is the main gameplay socket. Once connected, the client receives the current game state, can send player actions, and receives broadcasts of all actions and their resolved outcomes in real time.


How to Connect

Endpoint: ws://<host>/ws/game/<game_id>/

Authentication required: Yes. Unauthenticated connections are closed with code 4002.

The game_id must match an existing Game that the authenticated user belongs to. If the user is not a participant, the connection is rejected with code 4003.


Connection Lifecycle

  1. Client opens the WebSocket with the game_id received from PublicQueueConsumer or PrivateRoomConsumer.
  2. Server validates that the user is a participant.
  3. On success, the server immediately sends a game_state event to the connecting client with the full current game state.
  4. The client sends Action messages as the game progresses.
  5. Each valid action is broadcast as a game_action event, followed by a game_response event with the resolved outcome.
  6. All players in the game receive both broadcasts.

Messages: Client → Server

Game Action

Sends a player action for the current game phase. The type field must correspond to a valid Action type for the active phase. All other fields are action-specific.

{
    "type": "SomeActionType",
    "...": "action-specific fields"
}

The game and player fields are injected server-side — do not include them manually.

Chat Message
{
    "type": "ChatMessage",
    "msg": "Hello everyone!"
}

Messages: Server → Client

Game State

Sent immediately on connection. Contains the full serialized game state.

{
    "event_type": "game_state",
    "game_state": { "...": "full GameStatusSerializer output" }
}
Game Action

Broadcast to all players when a valid action is received. Contains the raw action data as sent by the acting player.

{
    "event_type": "game_action",
    "data": { "type": "SomeActionType", "game": 42, "player": 7, "...": "..." }
}
Game Response

Broadcast to all players immediately after game_action. Contains the resolved outcome of the action as serialized by GeneralResponseSerializer.

{
    "event_type": "game_response",
    "data": { "...": "GeneralResponseSerializer output" }
}
Chat Message

Broadcast to all players in the game.

{
    "event_type": "chat_message",
    "game": 42,
    "user": "alice",
    "msg": "Hello everyone!"
}
Error

Sent only to the client that triggered the error (invalid action, wrong phase, etc.).

{
    "event_type": "error",
    "message": "Acción no válida en la fase actual."
}

Close Codes

Code Meaning
4002 Unauthorized or missing route kwargs.
4003 User is not a participant in this game.

Important Notes

  • Every valid action triggers two consecutive broadcasts: game_action (what was sent) and game_response (the outcome). Always handle both.
  • Invalid actions (wrong phase, serialization errors, game logic errors) produce an error event and are not broadcast to other players.
  • The game state snapshot sent on connect may be slightly stale by the time the first game_action arrives; prefer the response stream for live updates.

Example Flow (JavaScript)

const socket = new WebSocket(`ws://localhost:8000/ws/game/${gameId}/`);

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);

    switch (data.event_type) {
        case "game_state":
            initializeBoard(data.game_state);
            break;
        case "game_action":
            highlightAction(data.data);
            break;
        case "game_response":
            applyOutcome(data.data);
            break;
        case "chat_message":
            appendChat(data.user, data.msg);
            break;
        case "error":
            showError(data.message);
            break;
    }
};

// Send an action:
socket.send(JSON.stringify({ type: "RollDice" }));

// Send a chat message:
socket.send(JSON.stringify({ type: "ChatMessage", msg: "Good luck!" }));
Source code in magnate/consumers.py
class GameConsumer(AsyncWebsocketConsumer):
    """
    WebSocket consumer for an active game session.

    This is the main gameplay socket. Once connected, the client receives the
    current game state, can send player actions, and receives broadcasts of
    all actions and their resolved outcomes in real time.

    ---
    ## How to Connect

    **Endpoint:** ``ws://<host>/ws/game/<game_id>/``

    **Authentication required:** Yes. Unauthenticated connections are closed with code ``4002``.

    The ``game_id`` must match an existing ``Game`` that the authenticated user
    belongs to. If the user is not a participant, the connection is rejected with
    code ``4003``.

    ---
    ## Connection Lifecycle

    1. Client opens the WebSocket with the ``game_id`` received from ``PublicQueueConsumer``
       or ``PrivateRoomConsumer``.
    2. Server validates that the user is a participant.
    3. On success, the server immediately sends a ``game_state`` event to the
       connecting client with the full current game state.
    4. The client sends ``Action`` messages as the game progresses.
    5. Each valid action is broadcast as a ``game_action`` event, followed by a
       ``game_response`` event with the resolved outcome.
    6. All players in the game receive both broadcasts.

    ---
    ## Messages: Client → Server

    ### Game Action
    Sends a player action for the current game phase. The ``type`` field must
    correspond to a valid ``Action`` type for the active phase. All other fields
    are action-specific.

    ```json
    {
        "type": "SomeActionType",
        "...": "action-specific fields"
    }
    ```

    The ``game`` and ``player`` fields are injected server-side — do **not** include
    them manually.

    ### Chat Message
    ```json
    {
        "type": "ChatMessage",
        "msg": "Hello everyone!"
    }
    ```

    ---
    ## Messages: Server → Client

    ### Game State
    Sent immediately on connection. Contains the full serialized game state.

    ```json
    {
        "event_type": "game_state",
        "game_state": { "...": "full GameStatusSerializer output" }
    }
    ```

    ### Game Action
    Broadcast to all players when a valid action is received. Contains the raw
    action data as sent by the acting player.

    ```json
    {
        "event_type": "game_action",
        "data": { "type": "SomeActionType", "game": 42, "player": 7, "...": "..." }
    }
    ```

    ### Game Response
    Broadcast to all players immediately after ``game_action``. Contains the
    resolved outcome of the action as serialized by ``GeneralResponseSerializer``.

    ```json
    {
        "event_type": "game_response",
        "data": { "...": "GeneralResponseSerializer output" }
    }
    ```

    ### Chat Message
    Broadcast to all players in the game.

    ```json
    {
        "event_type": "chat_message",
        "game": 42,
        "user": "alice",
        "msg": "Hello everyone!"
    }
    ```

    ### Error
    Sent only to the client that triggered the error (invalid action, wrong phase,
    etc.).

    ```json
    {
        "event_type": "error",
        "message": "Acción no válida en la fase actual."
    }
    ```

    ---
    ## Close Codes

    | Code | Meaning |
    |------|---------|
    | 4002 | Unauthorized or missing route kwargs. |
    | 4003 | User is not a participant in this game. |

    ---
    ## Important Notes

    - Every valid action triggers **two** consecutive broadcasts: ``game_action``
      (what was sent) and ``game_response`` (the outcome). Always handle both.
    - Invalid actions (wrong phase, serialization errors, game logic errors) produce
      an ``error`` event and are **not** broadcast to other players.
    - The game state snapshot sent on connect may be slightly stale by the time
      the first ``game_action`` arrives; prefer the response stream for live updates.

    ---
    ## Example Flow (JavaScript)

    ```js
    const socket = new WebSocket(`ws://localhost:8000/ws/game/${gameId}/`);

    socket.onmessage = (event) => {
        const data = JSON.parse(event.data);

        switch (data.event_type) {
            case "game_state":
                initializeBoard(data.game_state);
                break;
            case "game_action":
                highlightAction(data.data);
                break;
            case "game_response":
                applyOutcome(data.data);
                break;
            case "chat_message":
                appendChat(data.user, data.msg);
                break;
            case "error":
                showError(data.message);
                break;
        }
    };

    // Send an action:
    socket.send(JSON.stringify({ type: "RollDice" }));

    // Send a chat message:
    socket.send(JSON.stringify({ type: "ChatMessage", msg: "Good luck!" }));
    ```
    """
    async def connect(self):
        # Triggered when user joins a specific match ID (game really begins) -> add to Redis room group.
        scope_user = self.scope.get('user')
        if scope_user is None or getattr(scope_user, 'is_anonymous', True):
            await self.close(code=4002)
            return

        # Extraemos la instancia real de CustomUser de la BD
        self.user = await database_sync_to_async(CustomUser.objects.get)(pk=scope_user.pk)

        self.url = self.scope.get('url_route')
        if self.url is None:
            await self.close(code=4002)
            return

        kwargs = self.url.get('kwargs')
        if not kwargs or 'room_id' not in kwargs:
            await self.close(code=4002)
            return

        self.game_id = int(kwargs['room_id'])

        self.game_group_name = f"game_{self.game_id}"

        player_is_in_game = await self.is_player_in_game(self.user, self.game_id)

        if not player_is_in_game:
            await self.close(code=4003)
            return

        await self.channel_layer.group_add(
            self.game_group_name,
            self.channel_name
        )

        await self.accept()

        game = await self.get_game()
        game_state = await database_sync_to_async(
                lambda: GameStatusSerializer(game).data)()

        await self.channel_layer.send(
            self.channel_name,
            {
                'type': 'game_state',
                'game_state': game_state
            }
        )

    async def disconnect(self, close_code):
        # Triggered when user leaves game -> notify opponent.
        await self.channel_layer.group_discard(
            self.game_group_name,
            self.channel_name
        )


    async def receive(self, text_data):
        """
        Triggered when user sends a move -> broadcast to room group.
        Also manages game over conditions triggering disconnects.
        Manages DB interactions over purchases, rents etc
        """

        game = await self.get_game()
        if game is None:
            await self.send_error("Game not found")
            return

        data = json.loads(text_data)

        if data.get('type') == 'ChatMessage':
            message = data.get('msg')
            if message:
                await self.channel_layer.group_send(
                    self.game_group_name,
                    {
                        'type': 'chat_message',
                        'game': self.game_id,
                        'user': self.user.username,
                        'msg': message
                    }
                )
            return

        data['game'] = self.game_id
        data['player'] = self.user.pk

        action, errors = await validate_and_save_action(data)

        if errors or action is None:
            await self.send_error(f"Invalid data: {errors}")
            return

        action = cast(Action, action)

        try:
            response = await GameManager.process_action(game, self.user, action)

            if response is None:
                await database_sync_to_async(action.delete)() # Limpiamos BD
                await self.send_error("Acción no válida en la fase actual.")
                return

            # Broadcast action
            await self.channel_layer.group_send(
                self.game_group_name,
                {
                    'type': 'game_action_event',
                    'data': data
                }
            )

            response_data = await database_sync_to_async(lambda: GeneralResponseSerializer(response).data)()

            await self.channel_layer.group_send(
                self.game_group_name,
                {
                    'type': 'game_response_event',
                    'data': response_data
                }
            )

        except (MaliciousUserInput, GameLogicError, GameDesignError, Exception) as e:
            await database_sync_to_async(action.delete)()
            await self.send_error(f"{e}")

# --------------------- Handlers ---------------------- #

    async def game_state(self, event):
        await self.send(text_data=json.dumps({
            'event_type': 'game_state',
            'game_state': event['game_state']
        }))

    async def game_action_event(self, event):
        await self.send(text_data=json.dumps({
            'event_type': 'game_action',
            'data': event['data']
        }))

    async def game_response_event(self, event):
        await self.send(text_data=json.dumps({
            'event_type': 'game_response',
            'data': event['data']
        }))

    async def chat_message(self, event):
        await self.send(text_data=json.dumps({
            'event_type': 'chat_message',
            'game': event['game'],
            'user': event['user'],
            'msg': event['msg']
        }))

    async def send_error(self, message):
        await self.send(text_data=json.dumps({
            'event_type': 'error',
            'message': message
        }))


#----------------------- DB access --------------------#
    @database_sync_to_async
    def is_player_in_game(self, user, game_id):
        try:
            game = Game.objects.get(pk=game_id)
            return game.players.filter(pk=user.pk).exists()
        except Game.DoesNotExist:
            return False

    @database_sync_to_async
    def get_game(self):
        try:
            return Game.objects.get(pk=self.game_id)
        except Game.DoesNotExist:
            return None

receive(text_data) async

Triggered when user sends a move -> broadcast to room group. Also manages game over conditions triggering disconnects. Manages DB interactions over purchases, rents etc

Source code in magnate/consumers.py
async def receive(self, text_data):
    """
    Triggered when user sends a move -> broadcast to room group.
    Also manages game over conditions triggering disconnects.
    Manages DB interactions over purchases, rents etc
    """

    game = await self.get_game()
    if game is None:
        await self.send_error("Game not found")
        return

    data = json.loads(text_data)

    if data.get('type') == 'ChatMessage':
        message = data.get('msg')
        if message:
            await self.channel_layer.group_send(
                self.game_group_name,
                {
                    'type': 'chat_message',
                    'game': self.game_id,
                    'user': self.user.username,
                    'msg': message
                }
            )
        return

    data['game'] = self.game_id
    data['player'] = self.user.pk

    action, errors = await validate_and_save_action(data)

    if errors or action is None:
        await self.send_error(f"Invalid data: {errors}")
        return

    action = cast(Action, action)

    try:
        response = await GameManager.process_action(game, self.user, action)

        if response is None:
            await database_sync_to_async(action.delete)() # Limpiamos BD
            await self.send_error("Acción no válida en la fase actual.")
            return

        # Broadcast action
        await self.channel_layer.group_send(
            self.game_group_name,
            {
                'type': 'game_action_event',
                'data': data
            }
        )

        response_data = await database_sync_to_async(lambda: GeneralResponseSerializer(response).data)()

        await self.channel_layer.group_send(
            self.game_group_name,
            {
                'type': 'game_response_event',
                'data': response_data
            }
        )

    except (MaliciousUserInput, GameLogicError, GameDesignError, Exception) as e:
        await database_sync_to_async(action.delete)()
        await self.send_error(f"{e}")