Core Game Logic (games.py)

This module encapsulates the rules, state transitions, and actions of the game. It functions as the authoritative backend engine, ensuring that all moves are valid according to the game state.


🎲 Game State Machine Flow

The game operates as a finite state machine where players transition through specific phases. Any action sent by the frontend must be authorized by the GameManager based on the current active phase.


🛠 Utility & Data Functions

These functions handle the database-level interactions required to resolve game logic.

Core Game Logic Module.

This module handles the rules, state transitions, and actions of the game. It provides helper functions to calculate net worth, rent, building/demolishing rules, and a GameManager class that acts as a state machine for the different phases of a player's turn.

_get_square_by_custom_id(custom_id)

Retrieves a BaseSquare instance by its custom_id.

Parameters:

Name Type Description Default
custom_id int

The custom identifier of the square.

required

Returns:

Name Type Description
BaseSquare BaseSquare

The square instance.

Raises:

Type Description
GameLogicError

If no square with the given custom_id exists.

Source code in magnate/game_utils.py
def _get_square_by_custom_id(custom_id: int) -> BaseSquare:
    """
    Retrieves a BaseSquare instance by its custom_id.

    Args:
        custom_id (int): The custom identifier of the square.

    Returns:
        BaseSquare: The square instance.

    Raises:
        GameLogicError: If no square with the given custom_id exists.
    """
    square = BaseSquare.objects.filter(custom_id=custom_id).first()
    if square is None:
        raise GameLogicError(f"no square with id {custom_id}")

    return square

_get_user_square(game, user)

Retrieves the square where a specific user is currently located.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user whose position is being queried.

required

Returns:

Name Type Description
BaseSquare BaseSquare

The square where the user is currently standing.

Raises:

Type Description
GameLogicError

If the user is not part of the game.

Source code in magnate/game_utils.py
def _get_user_square(game: Game, user: CustomUser) -> BaseSquare:
    """
    Retrieves the square where a specific user is currently located.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user whose position is being queried.

    Returns:
        BaseSquare: The square where the user is currently standing.

    Raises:
        GameLogicError: If the user is not part of the game.
    """
    user_key = str(user.pk)

    if user_key not in game.positions:
        raise GameLogicError(f"user {user} not in the game")

    return _get_square_by_custom_id(game.positions[user_key])

_get_relationship(game, square)

Gets the ownership relationship between a game and a specific square.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
square BaseSquare

The property square to check.

required

Returns:

Type Description
Optional[PropertyRelationship]

Optional[PropertyRelationship]: The relationship object if owned, None otherwise.

Raises:

Type Description
GameLogicError

If more than one owner is found for the same square.

Source code in magnate/game_utils.py
def _get_relationship(game: Game, square: BaseSquare) -> Optional[PropertyRelationship]:
    """
    Gets the ownership relationship between a game and a specific square.

    Args:
        game (Game): The current game instance.
        square (BaseSquare): The property square to check.

    Returns:
        Optional[PropertyRelationship]: The relationship object if owned, None otherwise.

    Raises:
        GameLogicError: If more than one owner is found for the same square.
    """
    try:
        return PropertyRelationship.objects.get(game=game, square=square)
    except PropertyRelationship.DoesNotExist:
        return None
    except MultipleObjectsReturned:
        raise GameLogicError("more than one owners for the same square")

_get_jail_square()

Retrieves the designated Jail square for the board.

Returns:

Name Type Description
BaseSquare BaseSquare

The JailSquare instance.

Raises:

Type Description
GameDesignError

If there are no jail squares or too many jail squares.

Source code in magnate/game_utils.py
def _get_jail_square() -> BaseSquare:
    """
    Retrieves the designated Jail square for the board.

    Returns:
        BaseSquare: The JailSquare instance.

    Raises:
        GameDesignError: If there are no jail squares or too many jail squares.
    """
    try:
        return JailSquare.objects.get()
    except JailSquare.DoesNotExist:
        raise GameDesignError("there are no jail squares in the game")
    except MultipleObjectsReturned:
        raise GameDesignError("there are too many jail squares")

🏠 Property & Economy Rules

Calculates financial impacts, building constraints, and mortgage status.

Core Game Logic Module.

This module handles the rules, state transitions, and actions of the game. It provides helper functions to calculate net worth, rent, building/demolishing rules, and a GameManager class that acts as a state machine for the different phases of a player's turn.

_calculate_rent_price(game, user, square)

Calculates the rent price a user must pay when landing on a square.

Handles different logic for PropertySquares (houses), TramSquares, BridgeSquares, and ServerSquares based on ownership and multipliers.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user landing on the square.

required
square BaseSquare

The square being landed on.

required

Returns:

Name Type Description
int int

The calculated rent amount to pay. Returns 0 if unowned or owned by the user.

Raises:

Type Description
GameDesignError

If the rent arrays on the square are incorrectly configured.

GameLogicError

If an unexpected condition occurs during calculation.

Source code in magnate/game_utils.py
def _calculate_rent_price(game: Game, user: CustomUser, square: BaseSquare) -> int:
    """
    Calculates the rent price a user must pay when landing on a square.

    Handles different logic for PropertySquares (houses), TramSquares, 
    BridgeSquares, and ServerSquares based on ownership and multipliers.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user landing on the square.
        square (BaseSquare): The square being landed on.

    Returns:
        int: The calculated rent amount to pay. Returns 0 if unowned or owned by the user.

    Raises:
        GameDesignError: If the rent arrays on the square are incorrectly configured.
        GameLogicError: If an unexpected condition occurs during calculation.
    """
    # If it is not owned or is owned by the same user, no rent is paid
    prop_rel = _get_relationship(game, square)
    if not prop_rel or prop_rel.owner == user or prop_rel.mortgage: 
        return 0

    houses = prop_rel.houses

    if isinstance(square, PropertySquare):
        if not square.rent_prices or len(square.rent_prices) < 6:
            raise GameDesignError(f"Incorrect rent prices for square {square.custom_id}")
        if houses == -1:
            return square.rent_prices[0]
        elif houses == 0:
            return square.rent_prices[0] * 2  # grupo completo = doble del alquiler base
        elif houses == 1:
            return square.rent_prices[1]
        elif houses == 2:
            return square.rent_prices[2]
        elif houses == 3:
            return square.rent_prices[3]
        elif houses == 4:
            return square.rent_prices[4]
        elif houses == 5:  # hotel
            return square.rent_prices[5]

        return 0

    elif isinstance(square, TramSquare):
        # TODO
        return 0

    elif isinstance(square, BridgeSquare):
        property_owner = prop_rel.owner
        bridges_owned = PropertyRelationship.objects.filter(game=game, square__bridgesquare__isnull=False, owner=property_owner).count()
        if not square.rent_prices or len(square.rent_prices) < bridges_owned:
            raise GameDesignError(f"Incorrect rent prices for bridge {square.custom_id}")
        return square.rent_prices[bridges_owned - 1]

    elif isinstance(square, ServerSquare):
        property_owner = prop_rel.owner

        if not square.rent_prices or len(square.rent_prices) < 2:
            raise GameDesignError(f"Incorrect rent prices for square {square.custom_id}")

        squares = PropertyRelationship.objects.filter(game=game, square__serversquare__isnull=False, owner=property_owner)

        if squares.count() == 2:
            return square.rent_prices[1]
        elif squares.count() == 1:
            return square.rent_prices[0]
        else:
            # TODO: Write something
            raise GameLogicError()
    else:
        return 0

_build_square(game, user, building_square, number_built, free_build)

Builds houses or hotels on a property.

Validates that the user owns the complete color group and respects the uniform building rule.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user attempting to build.

required
building_square BaseSquare

The target property square.

required
number_built int

Number of buildings to construct.

required
free_build bool

If True, the user is not charged for the construction.

required

Returns:

Name Type Description
PropertyRelationship PropertyRelationship

The updated ownership relationship.

Raises:

Type Description
MaliciousUserInput

If the user does not own the full group, violates uniform building rules, or tries to build beyond the max limit.

GameLogicError

If negative house values are encountered.

Source code in magnate/game_utils.py
def _build_square(game: Game, 
                  user: CustomUser, 
                  building_square: BaseSquare, 
                  number_built: int,
                  free_build: bool) -> PropertyRelationship:
    """
    Builds houses or hotels on a property.

    Validates that the user owns the complete color group and respects 
    the uniform building rule.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user attempting to build.
        building_square (BaseSquare): The target property square.
        number_built (int): Number of buildings to construct.
        free_build (bool): If True, the user is not charged for the construction.

    Returns:
        PropertyRelationship: The updated ownership relationship.

    Raises:
        MaliciousUserInput: If the user does not own the full group, violates 
            uniform building rules, or tries to build beyond the max limit.
        GameLogicError: If negative house values are encountered.
    """
    # Check if it's  a property and take its group
    building_square = building_square.get_real_instance()

    if not isinstance(building_square, PropertySquare):
        raise MaliciousUserInput(user, "tried to build in a non property square")

    relationship = _get_relationship(game, building_square)

    if relationship is None:
        raise MaliciousUserInput(user, "no user owns this square")

    if relationship.owner != user:
        raise MaliciousUserInput(user, "tried to build in an unowned property")

    square_group = building_square.group
    actual_houses = relationship.houses

    # Check if user has every square in the group and if its a property
    total_squares_in_group = PropertySquare.objects.filter(
        board=building_square.board, 
        group=square_group
    ).count()

    group_relationships = PropertyRelationship.objects.filter(
        game=game, 
        owner=user, 
        square__propertysquare__group=square_group
    ).select_related('square')

    if group_relationships.count() != total_squares_in_group:
        raise MaliciousUserInput(user, "does not own the group")

    for rel in group_relationships:
        if rel.houses < 0: 
            raise GameLogicError(f"negative house value")
        elif actual_houses + number_built - 1 > rel.houses:
            raise MaliciousUserInput(user, "already owns more than other")

    if actual_houses == 5:
        raise MaliciousUserInput(user, "nothing more to build")

    relationship.houses += number_built
    relationship.save()

    stats = PlayerGameStatistic.objects.get(user=user,game=game)
    stats.built_houses += number_built

    if not free_build:
        coste = building_square.build_price * number_built

        game.money[str(user.pk)] -= coste
        game.save()
        stats.lost_money += coste

    stats.save()

    return relationship  #ack

_demolish_square(game, user, demolition_square, number_demolished, free_demolish)

Demolishes houses/hotels on a property and returns money to the user.

Ensures that uniform building rules are respected during demolition.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user attempting to demolish.

required
demolition_square BaseSquare

The square where buildings will be demolished.

required
number_demolished int

The amount of houses to demolish.

required
free_demolish bool

If True, no money is refunded to the user.

required

Returns:

Name Type Description
PropertyRelationship PropertyRelationship

The updated ownership relationship.

Raises:

Type Description
MaliciousUserInput

If the property is unowned, owned by someone else, is not a property square, or violates uniform building rules.

Source code in magnate/game_utils.py
def _demolish_square(game: Game, 
                     user: CustomUser, 
                     demolition_square: BaseSquare, 
                     number_demolished: int,
                     free_demolish: bool) -> PropertyRelationship:
    """
    Demolishes houses/hotels on a property and returns money to the user.

    Ensures that uniform building rules are respected during demolition.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user attempting to demolish.
        demolition_square (BaseSquare): The square where buildings will be demolished.
        number_demolished (int): The amount of houses to demolish.
        free_demolish (bool): If True, no money is refunded to the user.

    Returns:
        PropertyRelationship: The updated ownership relationship.

    Raises:
        MaliciousUserInput: If the property is unowned, owned by someone else, 
            is not a property square, or violates uniform building rules.
    """
    # Check if it's a property
    demolition_square = demolition_square.get_real_instance()
    if not isinstance(demolition_square, PropertySquare):
        raise MaliciousUserInput(user, "tried to demolish a non property square")

    relationship = _get_relationship(game, demolition_square)

    if relationship is None:
        raise MaliciousUserInput(user, "no user owns this square")

    if relationship.owner != user:
        raise MaliciousUserInput(user, "tried to demolish an unowned property")

    actual_houses = relationship.houses

    if actual_houses < number_demolished:
        raise MaliciousUserInput(user, "tried to demolish more houses than are built")

    square_group = demolition_square.group

    group_relationships = PropertyRelationship.objects.filter(
        game=game, 
        owner=user, 
        square__propertysquare__group=square_group
    ).select_related('square')

    # Check if we can demolish -> respect rule 
    for rel in group_relationships:
        if (actual_houses - number_demolished) < (rel.houses - 1):
            raise MaliciousUserInput(user, "unable to demolish so many houses: violates the uniform building rule")

    # demolish
    relationship.houses -= number_demolished
    relationship.save()

    stats = PlayerGameStatistic.objects.get(user=user,game=game)
    stats.demolished_houses += number_demolished

    if not free_demolish:
        coste = demolition_square.build_price

        game.money[str(user.pk)] += coste // 2 * number_demolished
        game.save()
        stats.won_money += coste // 2 * number_demolished

    stats.save()

    return relationship

_set_mortgage(game, user, target_square, free_mortgage)

Mortgages a property to receive immediate funds.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user attempting the mortgage.

required
target_square BaseSquare

The property to be mortgaged.

required
free_mortgage bool

If True, no money is added to the user's balance.

required

Returns:

Name Type Description
PropertyRelationship PropertyRelationship

The updated relationship.

Raises:

Type Description
MaliciousUserInput

If not owned, already mortgaged, or wrong type of square.

GameLogicError

If trying to mortgage a property that still has houses built on it.

Source code in magnate/game_utils.py
def _set_mortgage(game: Game, user: CustomUser, target_square: BaseSquare, free_mortgage: bool) -> PropertyRelationship:
    """
    Mortgages a property to receive immediate funds.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user attempting the mortgage.
        target_square (BaseSquare): The property to be mortgaged.
        free_mortgage (bool): If True, no money is added to the user's balance.

    Returns:
        PropertyRelationship: The updated relationship.

    Raises:
        MaliciousUserInput: If not owned, already mortgaged, or wrong type of square.
        GameLogicError: If trying to mortgage a property that still has houses built on it.
    """
    target_square = target_square.get_real_instance()
    if not (isinstance(target_square, PropertySquare) or
            isinstance(target_square, BridgeSquare) or
            isinstance(target_square, ServerSquare)):
        raise MaliciousUserInput(user, "tried to mortgage a non property/bridge/server square")

    relationship = _get_relationship(game=game, square=target_square)

    if relationship is None:
        raise MaliciousUserInput(user, "no user owns this square")

    if relationship.owner != user:
        raise MaliciousUserInput(user, "tried to mortgage an unowned property")

    if relationship.mortgage:
        raise MaliciousUserInput(user, "tried to mortgage an already mortgaged property")



    if isinstance(target_square, PropertySquare):
        if relationship.houses > 0:
            raise GameLogicError("tried to mortgage a property with houses")

    relationship.mortgage = True
    relationship.save()
    stats = PlayerGameStatistic.objects.get(user=user,game=game)
    stats.num_mortgages += 1

    if not free_mortgage:
        mortgage_value = target_square.buy_price // 2
        game.money[str(user.pk)] += mortgage_value

        stats.won_money += mortgage_value
        game.save()

    stats.save()

    return relationship

_unset_mortgage(game, user, target_square, free_unset_mortgage)

Lifts the mortgage from a property by paying the required fee.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user lifting the mortgage.

required
target_square BaseSquare

The property to unmortgage.

required
free_unset_mortgage bool

If True, the user is not charged.

required

Returns:

Name Type Description
PropertyRelationship PropertyRelationship

The updated relationship.

Raises:

Type Description
MaliciousUserInput

If not owned, not mortgaged, or wrong type of square.

Source code in magnate/game_utils.py
def _unset_mortgage(game: Game, user: CustomUser, target_square: BaseSquare, free_unset_mortgage: bool) -> PropertyRelationship:
    """
    Lifts the mortgage from a property by paying the required fee.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user lifting the mortgage.
        target_square (BaseSquare): The property to unmortgage.
        free_unset_mortgage (bool): If True, the user is not charged.

    Returns:
        PropertyRelationship: The updated relationship.

    Raises:
        MaliciousUserInput: If not owned, not mortgaged, or wrong type of square.
    """
    target_square = target_square.get_real_instance()
    if not (isinstance(target_square, PropertySquare) or
            isinstance(target_square, BridgeSquare) or
            isinstance(target_square, ServerSquare)):
        raise MaliciousUserInput(user, "tried to unset mortgage a non property/bridge/server square")

    relationship = _get_relationship(game=game, square=target_square)

    if relationship is None:
        raise MaliciousUserInput(user, "no user owns this square")

    if relationship.owner != user:
        raise MaliciousUserInput(user, "tried to unset mortgage an unowned property")

    if not relationship.mortgage:
        raise MaliciousUserInput(user, "tried to unset mortgage a not mortgaged property")

    relationship.mortgage = False
    relationship.save()
    target_square = target_square.get_real_instance()

    if not free_unset_mortgage:
        mortgage_value = target_square.buy_price // 2
        game.money[str(user.pk)] -= mortgage_value
        game.save()
        stats = PlayerGameStatistic.objects.get(user=user,game=game)
        stats.lost_money += mortgage_value


    return relationship

⚙️ GameManager

The primary gateway for all frontend actions. Every action sent via WebSocket or REST must be processed through the process_action method.

magnate.games.GameManager

State machine and core processor for Game Actions.

The GameManager encapsulates the logic for each specific phase of a player's turn, processing polymorphic Action objects and mutating the database state accordingly.

Source code in magnate/games.py
class GameManager:
    """
    State machine and core processor for Game Actions.

    The GameManager encapsulates the logic for each specific phase of a 
    player's turn, processing polymorphic Action objects and mutating the 
    database state accordingly.
    """
    ###########################################################################
    # Phase logic
    ###########################################################################

    ROLL_THE_DICES = Game.GamePhase.roll_the_dices
    CHOOSE_SQUARE = Game.GamePhase.choose_square
    MANAGEMENT = Game.GamePhase.management
    BUSINESS = Game.GamePhase.business
    ANSWER_TRADE_PROPOSAL = Game.GamePhase.proposal_acceptance
    LIQUIDATION = Game.GamePhase.liquidation
    AUCTION = Game.GamePhase.auction
    PROPOSAL_ACCEPTANCE = Game.GamePhase.proposal_acceptance
    CHOOSE_FANTASY = Game.GamePhase.choose_fantasy
    END_GAME = Game.GamePhase.end_game


    @classmethod
    @database_sync_to_async
    def process_action(cls, game: Game, user: CustomUser, action: Action) -> Response:
        """
        The only public method exposed in the API. It processes each action
        in dedicated functions depending on the current phase and returns
        a Response.

        Args:
            game (Game): The current active game instance.
            user (CustomUser): The user performing the action.
            action (Action): The deserialized action payload.

        Returns:
            Response: A response object representing the outcome.

        Raises:
            MaliciousUserInput: If the user acts out of turn or phase.
            GameLogicError: If an unrecognized phase is encountered.
        """


        if isinstance(action, ActionSurrender):
            # TODO
            cls._bankrupt_player(game, user)
            response = Response()
            return _add_basic_response_data(game, response)

        if user != game.active_phase_player and not isinstance(action, ActionBid): # if aucction there are no turns
            raise MaliciousUserInput(user, "is not the active player")



        if game.phase == cls.ROLL_THE_DICES:
            if isinstance(action, ActionPayBail):
                response = cls._pay_bail_logic(game, user, action)
            elif isinstance(action, ActionThrowDices):
                response = cls._roll_dices_logic(game, user, action)
            else:
                raise MaliciousUserInputAction(game, user, action)
        elif game.phase == cls.CHOOSE_SQUARE:
            if not isinstance(action, ActionMoveTo):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._square_chosen_logic(game, user, action)
        elif game.phase == cls.CHOOSE_FANTASY:
            if not isinstance(action, ActionChooseCard):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._choose_fantasy_logic(game, user, action)
        elif game.phase == cls.MANAGEMENT:
            if not isinstance(action, (ActionBuySquare, ActionDropPurchase, ActionTakeTram, ActionNextPhase)):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._management_logic(game, user, action)
        elif game.phase == cls.BUSINESS or game.phase == cls.LIQUIDATION:
            if not isinstance(action, (ActionBuild, ActionDemolish, ActionTradeProposal, ActionMortgageSet, ActionMortgageUnset, ActionNextPhase)):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._business_logic(game, user, action)
        elif game.phase == cls.ANSWER_TRADE_PROPOSAL:
            if not isinstance(action, ActionTradeAnswer):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._answer_trade_proposal_logic(game, user, action)
        elif game.phase == cls.AUCTION:
            if not isinstance(action, ActionBid):
                raise MaliciousUserInputAction(game, user, action)
            response = cls._bid_property_auction_logic(game, user, action)
        elif game.phase == cls.END_GAME:
            # TODO: check instance???
            # response = cls._end_game_logic(game,user,action)
            return cls._end_game_logic(game,user,action)
        else: 
            raise GameLogicError(f"Fase no reconocida o no manejada: {game.phase}")

        return _add_basic_response_data(game, response)

    @staticmethod
    def _pay_bail_logic(game: Game, user: CustomUser, action: ActionPayBail) -> Response:
        """
        Processes the action to pay the jail bail.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The jailed user.
            action (ActionPayBail): The bail action payload.

        Returns:
            Response: The standard response.

        Raises:
            MaliciousUserInput: If the user is not actually in jail.
            GameLogicError: If the user does not have enough money.
        """

        GameManager._cancel_all_timers(game)

        square = _get_user_square(game, user).get_real_instance()

        if not isinstance(square, JailSquare):
            raise MaliciousUserInput(user, "is not in jail")

        remaining = game.jail_remaining_turns.get(str(user.pk), 0)
        if remaining == 0:
            raise MaliciousUserInput(user, "is not in jail (no turns remaining)")

        bail_price = square.bail_price

        if game.money[str(user.pk)] < bail_price:
            raise GameLogicError("not enough money to pay bail")

        game.money[str(user.pk)] -= bail_price
        game.jail_remaining_turns[str(user.pk)] = 0
        game.save()

        stats = PlayerGameStatistic.objects.get(user=user,game=game)
        stats.lost_money += bail_price
        stats.save()
        # roll the dices -> continue normally
        GameManager._set_next_phase_timer(game, user)

        game.save()
        return Response()

    @staticmethod
    def _roll_dices_logic(game: Game, user: CustomUser, action: ActionThrowDices) -> Response: 
        """
        Handles rolling the dice, checking for doubles/triples, and resolving jail mechanics.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The user throwing the dice.
            action (ActionThrowDices): The dice throw action payload.

        Returns:
            Response: Standard response.
        """

        GameManager._cancel_all_timers(game)

        response: ResponseThrowDices = ResponseThrowDices()

        d1 = random.randint(1,6)
        d2 = random.randint(1,6)
        d3 = random.randint(1,6) # 4-6 are the bus faces

        response.dice1, response.dice2, response.dice_bus = d1, d2, d3

        triples = d3 <= 3 and (d1 == d2 == d3)
        doubles = (d1 == d2) and not triples

        response.triple = triples

        current_pos_square = _get_user_square(game, user).get_real_instance()
        current_pos_id = current_pos_square.custom_id

        # jail logic
        remaining_jail_turns = game.jail_remaining_turns.get(str(user.pk), 0)
        is_jailed = remaining_jail_turns > 0

        if is_jailed:
            jail_sq = current_pos_square
            if isinstance(jail_sq, JailSquare):
                if remaining_jail_turns == 1: #obligado a salir
                    game.money[str(user.pk)] -= jail_sq.bail_price
                    game.jail_remaining_turns[str(user.pk)] = 0
                    stats = PlayerGameStatistic.objects.get(user=user,game=game)
                    stats.turns_in_jail += 1
                    stats.lost_money += jail_sq.bail_price
                    stats.save()
                elif doubles: #sale gratis
                    game.jail_remaining_turns[str(user.pk)] = 0
                    game.streak = 0
                else:
                    # stays in jail
                    game.jail_remaining_turns[str(user.pk)] -= 1
                    game.phase = GameManager.BUSINESS
                    game.save()
                    stats = PlayerGameStatistic.objects.get(user=user,game=game)
                    stats.turns_in_jail += 1
                    stats.save()
                    response.path = [current_pos_id]
                    GameManager._set_next_phase_timer(game, user)
                    return response
            else:
                raise GameLogicError(f"Cannot be in jail status and not in jail square")

        # Not jailed
        if triples:
            # path current -> decided in chosen
            response.triple = True
            square = current_pos_square
            all_squares = BaseSquare.objects.filter(board=square.board)
            # All squares are suitable destinations
            possible_destinations = [s.custom_id for s in all_squares]
            possible_destinations.remove(_get_jail_square().custom_id)
            game.possible_destinations = {str(c_id): 0 for c_id in possible_destinations}
            response.destinations = possible_destinations
            game.phase = GameManager.CHOOSE_SQUARE
            response.path = [current_pos_id]
            game.save()
            GameManager._set_next_phase_timer(game, user)
            return response
        elif doubles: # doubles streak only if not getting out of jail via doubles
            if game.streak >= 2:
                # path -> current and jail
                jail_square = _get_jail_square()
                response.destinations = [jail_square.custom_id]
                game.streak = 0
                response.streak = game.streak

                game.positions[str(user.pk)] = jail_square.custom_id
                game.jail_remaining_turns[str(user.pk)] = 3
                response.path = [current_pos_id, jail_square.custom_id]

                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.times_in_jail += 1
                stats.save()

                game.phase = GameManager.LIQUIDATION

                game.save()
                GameManager._set_next_phase_timer(game, user)
                return response
            elif not is_jailed:
                game.streak = game.streak + 1
        else:
            game.streak = 0

        response.streak = game.streak

        # Hasn't gone to jail
        dice_combinations = _compute_dice_combinations(d1, d2, d3)
        game.possible_destinations, passed_go_map = _get_possible_destinations_ids(game, user, dice_combinations)

        response.destinations = list(game.possible_destinations.keys())
        if len(game.possible_destinations) > 1:
            # path in square chosen logic
            game.phase = GameManager.CHOOSE_SQUARE
            response.path = [current_pos_id]
        else:
            stats = PlayerGameStatistic.objects.get(user=user,game=game)
            stats.walked_squares += dice_combinations[0]
            stats.save()
            dest_square_id = next(iter(game.possible_destinations))
            steps = game.possible_destinations[dest_square_id]

            # Use _move_player_logic to get the traversed path and check for "Go to Jail" or "Passing Go".
            move_result = _move_player_logic(current_pos_square, steps)
            response.path = move_result["path"]

            if move_result["jailed"]:
                # landing in go to jail; update state to jail the player.
                jail = JailSquare.objects.first()
                if jail is None:
                    raise GameDesignError('no jail in game')

                game.positions[str(user.pk)] = jail.custom_id
                game.jail_remaining_turns[str(user.pk)] = 3
                game.phase = GameManager.LIQUIDATION
                stats = PlayerGameStatistic.objects.get(user=user, game=game)
                stats.times_in_jail += 1
                stats.save()
            else:
                game.positions[str(user.pk)] = dest_square_id
                square = _get_square_by_custom_id(dest_square_id)
                _apply_square_arrival(game, user, response, square, move_result["passed_go"])


        game.save()
        GameManager._set_next_phase_timer(game, user)
        return response

    @staticmethod
    def _square_chosen_logic(game: Game, user: CustomUser, action: Action) -> Response:
        """
        Handles the logic when a user selects their final destination (if multiple choices were given).

        Args:
            game (Game): The current game instance.
            user (CustomUser): The current user.
            action (ActionMoveTo): Action indicating the chosen square.

        Returns:
            Response: Standard response.

        Raises:
            MaliciousUserInput: If the chosen square is not in `possible_destinations`.
        """

        GameManager._cancel_all_timers(game)
        response: ResponseChooseSquare = ResponseChooseSquare()

        if not isinstance(action, ActionMoveTo):
            raise MaliciousUserInputAction(game, user, action)

        square = action.square

        if str(square.custom_id) not in game.possible_destinations:
            raise MaliciousUserInput(user, "tried to move to an illegal square")

        current_pos_id = game.positions[str(user.pk)]
        current_pos_square = _get_square_by_custom_id(current_pos_id).get_real_instance()

        steps = game.possible_destinations.get(str(square.custom_id))

        move_result = _move_player_logic(current_pos_square, steps)

        response.path = move_result["path"]

        if move_result["jailed"]:
            # landing in go to jail; update state to jail the player.
            jail = JailSquare.objects.first()
            if jail is None:
                raise GameDesignError('no jail in game')

            game.positions[str(user.pk)] = jail.custom_id
            game.jail_remaining_turns[str(user.pk)] = 3
            game.phase = GameManager.LIQUIDATION
            stats = PlayerGameStatistic.objects.get(user=user, game=game)
            stats.times_in_jail += 1
            stats.save()
        else:
            game.positions[str(user.pk)] = square.custom_id
            square = _get_square_by_custom_id(square.custom_id)
            _apply_square_arrival(game, user, response, square, move_result["passed_go"])

        stats = PlayerGameStatistic.objects.get(user=user,game=game)
        stats.walked_squares += steps
        stats.save()

        game.possible_destinations = dict()
        game.save()

        GameManager._set_next_phase_timer(game, user)
        return response

    @staticmethod
    def _choose_fantasy_logic(game: Game, user: CustomUser, action: ActionChooseCard) -> Response:
        GameManager._cancel_all_timers(game)

        response = ResponseChooseFantasy()
        fantasy_event = game.fantasy_event
        generate = not action.chosen_revealed_card
        new_fantasy = None

        if not generate:
            apply_fantasy_event(game, user, fantasy_event)
            response.fantasy_event = fantasy_event
            game.fantasy_event = None

        else:
            new_fantasy = FantasyEventFactory.generate()
            apply_fantasy_event(game, user, new_fantasy)
            response.fantasy_event = new_fantasy
            game.fantasy_event = None


        if game.streak == 0:
            game.phase = GameManager.BUSINESS
        else:
            game.phase = GameManager.ROLL_THE_DICES

        if game.phase == GameManager.BUSINESS:
            GameManager._set_next_phase_timer(game, user)
        elif game.phase == GameManager.ROLL_THE_DICES:
            GameManager._set_kick_out_timer(game, user)

        game.save()     
        return response

    @staticmethod
    def _management_logic(game: Game, user: CustomUser, action: Action) -> Response:
        """
        Logic of management phase, where the user can buy properties, pay bills etc.

        It checks the user's action against the current square they are on to update 
        the game state and transition to the next phase.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The acting user.
            action (Action): The payload of the action taken (e.g., ActionBuySquare).

        Returns:
            Response: Standard response.

        Raises:
            MaliciousUserInputAction: If the action does not fit the phase/context.
        """
        GameManager._cancel_all_timers(game)

        current_square = _get_user_square(game, user).get_real_instance()
        prop_rel = _get_relationship(game, current_square)

        if isinstance(action, ActionBuySquare):
            if isinstance(current_square, PropertySquare):
                # TODO: Check money
                game.money[str(user.pk)] -= current_square.buy_price
                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.lost_money += current_square.buy_price
                stats.save()
                new_property = PropertyRelationship(game=game, square=current_square, owner=user)
                user_properties = PropertyRelationship.objects.filter(game=game, owner=user)
                user_same_group_properties = user_properties.filter(square__propertysquare__group=current_square.group)

                group_squares = PropertySquare.objects.filter(group=current_square.group, board = current_square.board)

                if user_same_group_properties.count() == group_squares.count() - 1:
                    new_property.houses = 0
                    user_same_group_properties.update(houses=0)
                else: 
                    new_property.houses = -1
                new_property.save()

            elif isinstance(current_square, ServerSquare):
                game.money[str(user.pk)] -= current_square.buy_price
                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.lost_money += current_square.buy_price
                stats.save()
                new_property = PropertyRelationship(game=game, square=current_square, owner=user)
                new_property.save()

            elif isinstance(current_square, BridgeSquare):
                game.money[str(user.pk)] -= current_square.buy_price
                stats = PlayerGameStatistic.objects.get(user=user, game=game)
                stats.lost_money += current_square.buy_price
                stats.save()
                new_property = PropertyRelationship(game=game, square=current_square, owner=user)
                new_property.save()

            else:
                raise MaliciousUserInputAction(game, user, action)
        elif isinstance(action, ActionDropPurchase):
            if isinstance(action.square, (PropertySquare, ServerSquare, BridgeSquare)):
                return GameManager._initiate_auction(game, action.square)
            else:
                raise MaliciousUserInputAction(game, user, action)
        elif isinstance(action, ActionTakeTram):
            if isinstance(current_square, TramSquare):
                square = action.square
                tram_squares = TramSquare.objects.filter()
                tram_square_actual_id = game.positions[str(user.pk)]
                tram_squares_extern_ids = [s.custom_id for s in tram_squares]

                if square.custom_id == tram_square_actual_id: # case stay in the same square, free
                    pass
                elif square.custom_id in tram_squares_extern_ids: # Move to another square
                    if game.money[str(user.pk)] < square.buy_price:
                        raise MaliciousUserInput(user, "does not have enough money to take tram")
                    game.money[str(user.pk)] -= square.buy_price
                    stats = PlayerGameStatistic.objects.get(user=user,game=game)
                    stats.lost_money += square.buy_price
                    stats.save()
                    game.positions[str(user.pk)] = square.custom_id
                else:
                    raise MaliciousUserInput(user, "tried to take a tram to a non tram square")
            else:
                raise MaliciousUserInputAction(game, user, action)
        elif isinstance(action, ActionNextPhase):
            # TODO: Remove?
            pass
        else:
            raise MaliciousUserInputAction(game, user, action)

        if game.phase == GameManager.MANAGEMENT:
            if game.streak == 0:
                game.phase = GameManager.BUSINESS
            else:
                game.phase = GameManager.ROLL_THE_DICES

        if game.phase == GameManager.BUSINESS:
            GameManager._set_next_phase_timer(game, user)
        elif game.phase == GameManager.ROLL_THE_DICES:
            GameManager._set_kick_out_timer(game, user)

        game.save()
        return Response()

    @staticmethod
    def _business_logic(game: Game, user: CustomUser, action: Action) -> Response:
        """
        Unifies the business and liquidation phases. Handles building, demolishing, 
        trading, mortgaging, and proceeding to the next turn.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The active user.
            action (Action): The payload of the action taken.

        Returns:
            Response: Standard response.

        Raises:
            MaliciousUserInput: If restrictions (e.g., negative balance on next turn) are unmet.
        """

        GameManager._cancel_all_timers(game)
        # Logic for the business phase where players can build houses, trade, etc.
        if isinstance(action, ActionBuild):
            # Check owner 
            building_square = action.square
            relationship = _build_square(game, user, building_square, action.houses, False)

        elif isinstance(action, ActionDemolish):
            # Similiar to build
            demolition_square = action.square
            relationship = _demolish_square(game, user, demolition_square, action.houses, False)

        elif isinstance(action, ActionTradeProposal):
            relationship = GameManager._propose_trade(game, user, action)

        elif isinstance(action, ActionMortgageSet):
            relationship = _set_mortgage(game, user, action.square, False)

        elif isinstance(action, ActionMortgageUnset):
            relationship = _unset_mortgage(game, user, action.square, False)        
        elif isinstance(action, ActionNextPhase):
            current_money = game.money[str(user.pk)]


            if game.phase == GameManager.BUSINESS:
                if current_money >= 0:
                    GameManager._next_turn(game, user)
                else:
                    game.phase = GameManager.LIQUIDATION
                    game.save()

            elif game.phase == GameManager.LIQUIDATION:
                if current_money >= 0:
                    GameManager._next_turn(game, user)
                else:
                    raise MaliciousUserInput(user, "Cannot end in NEGATIVE")
            return Response() # timers set in next turn

        GameManager._set_next_phase_timer(game, user)
        return Response()

    @staticmethod
    def _answer_trade_proposal_logic(game: Game, user: CustomUser, action: Action) -> Response:
        """
        Processes a user's answer (accept/reject) to an active trade proposal.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The targeted user responding to the offer.
            action (ActionTradeAnswer): The user's response payload.

        Returns:
            Response: Standard response.

        Raises:
            MaliciousUserInput: If an unauthorized user attempts to answer, 
                or references an invalid proposal.
        """
        if isinstance(action, ActionTradeAnswer):
            offer = action.proposal

            offering = offer.player

            offered_money = offer.offered_money
            asked_money = offer.asked_money
            offered_properties = offer.offered_properties
            asked_properties = offer.asked_properties

            # TODO: Verify no player goes to negative (unless liquidation)

            if user != offer.destination_user:
                raise MaliciousUserInput(user, f"cannot accept proposal {offer}")

            if offer != game.proposal:
                raise MaliciousUserInput(user, f"tried to reference a non-existent proposal")

            if action.choose:
                for relationship in offered_properties.all():
                    relationship.owner = user
                    relationship.houses = -1 # reset houses
                    relationship.save()

                for relationship in asked_properties.all():
                    relationship.owner = offering
                    relationship.houses = -1
                    relationship.save()

                final_money = offered_money - asked_money

                game.money[str(offering.pk)] -= offered_money
                game.money[str(offering.pk)] += asked_money

                stats = PlayerGameStatistic.objects.get(user=offering,game=game)
                if final_money > 0:
                    stats.lost_money += abs(final_money)
                else:
                    stats.won_money += abs(final_money)
                stats.num_trades += 1
                stats.save()

                game.money[str(user.pk)] -= asked_money
                game.money[str(user.pk)] += offered_money

                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                if final_money > 0:
                    stats.won_money += abs(final_money)
                else:
                    stats.lost_money += abs(final_money)
                stats.num_trades += 1
                stats.save()

                game.save()

            game.phase = GameManager.BUSINESS
            game.active_phase_player = offering
            game.save()
            GameManager._set_next_phase_timer(game, offering)

            return Response()
        else:
            raise MaliciousUserInputAction(game, user, action)

    @staticmethod
    def _initiate_auction(game: Game, square: BaseSquare) -> Response:
        """
        Transitions the game into an AUCTION phase for a dropped property.

        Args:
            game (Game): The current game instance.
            square (BaseSquare): The property square going up for auction.

        Returns:
            Response: Standard response.
        """
        GameManager._cancel_all_timers(game)

        game.next_phase_task_id = None
        game.phase = GameManager.AUCTION

        auction = Auction.objects.create(game=game, square=square, is_active=True, bids = {})
        game.current_auction = auction
        game.save()

        # TODO: Remove magic number
        GameManager._set_auction_timer(game)
        # TODO: Remove in production
        game.refresh_from_db()

        return Response()

    @staticmethod
    def _bid_property_auction_logic(game: Game, user: CustomUser, action: Action) -> Response:
        """
        Registers a player's bid during an active auction phase.

        Args:
            game (Game): The current game instance.
            user (CustomUser): The user placing the bid.
            action (ActionBid): The action carrying the bid amount.

        Returns:
            Response: Standard response.

        Raises:
            GameLogicError: If auction state is corrupted.
            MaliciousUserInput: If the user bids twice or exceeds their balance.
        """
        if not isinstance(action, ActionBid):
            raise MaliciousUserInputAction(game, user, action)

        auction = game.current_auction
        if not auction:
            raise GameLogicError("No active auction")


        bids = auction.bids
        # user has not bid yet
        if bids.get(str(user.pk)):
            raise MaliciousUserInput(user, "User already placed a bid in this auction")

        # user who started the bid cant bid
        dropped = ActionDropPurchase.objects.filter(game=game, player=user, square=auction.square).exists()
        if dropped:
            raise MaliciousUserInput(user, "cannot bid in an auction they triggered")

        # user has enough money -> compulsory for auctions
        amount = action.amount
        if amount > game.money[str(user.pk)]:
            raise MaliciousUserInput(user, "Bid amount exceeds current balance")


        bids[str(user.pk)] = amount
        auction.bids = bids
        auction.save()

        game.save()
        return Response()

    @staticmethod
    def _end_auction(game: Game) -> Response | None:
        """
        Ends an active auction, resolves the winner based on the highest bid, 
        handles ties, and transitions the game state back to BUSINESS.

        Args:
            game (Game): The current game instance.

        Returns:
            Auction: The finalized auction object.

        Raises:
            GameLogicError: If not in AUCTION phase or state is missing square ID.
        """
        ## call this with a timer -> end of the auction

        if game.phase == GameManager.END_GAME:
            return None # Inevitable if the auction callback is triggered after the game has ended, just ignore it

        if game.phase != GameManager.AUCTION:
            raise GameLogicError("Tried to end auction but game is not in auction phase")

        auction = game.current_auction
        if not auction:
            raise GameLogicError("Game is in AUCTION phase but no auction object found")

        square = auction.square.get_real_instance()
        bids = auction.bids


        # no one bid
        if not bids:
            if game.streak == 0:
                game.phase = GameManager.BUSINESS
            else:
                game.phase = GameManager.ROLL_THE_DICES

            auction.is_active = False
            auction.winner = None
            auction.final_amount = 0
            auction.is_tie = False
            auction.save()

            game.current_auction = None
            game.save()

            if game.phase == GameManager.BUSINESS:
                GameManager._set_next_phase_timer(game, game.active_turn_player)
            elif game.phase == GameManager.ROLL_THE_DICES:
                GameManager._set_kick_out_timer(game, game.active_turn_player)

            game.save()
            return ResponseAuction(auction=auction)

        max_bid_amount = max(bids.values())
        winners = [int(uid) for uid, amt in bids.items() if amt == max_bid_amount]

        if len(winners)> 1:
            if game.streak == 0:
                game.phase = GameManager.BUSINESS
            else:
                game.phase = GameManager.ROLL_THE_DICES

            auction.is_active = False
            auction.winner = None
            auction.final_amount = max_bid_amount
            auction.is_tie = True
            auction.save()

            game.current_auction = None
            game.save()

            if game.phase == GameManager.BUSINESS:
                GameManager._set_next_phase_timer(game, game.active_turn_player)
            elif game.phase == GameManager.ROLL_THE_DICES:
                GameManager._set_kick_out_timer(game, game.active_turn_player)

            game.save()
            return ResponseAuction(auction=auction)

        winner_id = winners[0]
        if str(winner_id) not in game.money: # surrends mid auction -> no winner
            game.phase = GameManager.BUSINESS if game.streak == 0 else GameManager.ROLL_THE_DICES
            auction.is_active = False
            auction.save()

            game.current_auction = None
            game.save()

            if game.phase == GameManager.BUSINESS:
                GameManager._set_next_phase_timer(game, game.active_turn_player)
            elif game.phase == GameManager.ROLL_THE_DICES:
                GameManager._set_kick_out_timer(game, game.active_turn_player)

            game.save()
            return ResponseAuction(auction=auction)

        winner = CustomUser.objects.get(pk=winner_id)
        highest_bid = max_bid_amount

        game.money[str(winner.pk)] -= highest_bid
        stats = PlayerGameStatistic.objects.get(user=winner,game=game)
        stats.lost_money += highest_bid
        stats.save()

        new_property = PropertyRelationship(game=game, square=square, owner=winner)

        # groups n houses
        if isinstance(square, PropertySquare):
            user_properties = PropertyRelationship.objects.filter(game=game, owner=winner)
            user_same_group_properties = user_properties.filter(square__propertysquare__group=square.group)
            group_squares = PropertySquare.objects.filter(group=square.group, board = square.board)

            if user_same_group_properties.count() == group_squares.count() - 1:
                new_property.houses = 0
                user_same_group_properties.update(houses=0)
            else: 
                new_property.houses = -1
        else:
            new_property.houses = -1

        new_property.save()

        auction.winner = winner
        auction.final_amount = highest_bid
        auction.is_active = False
        auction.is_tie = False
        auction.save()

        if game.streak == 0:
            game.phase = GameManager.BUSINESS
        else:
            game.phase = GameManager.ROLL_THE_DICES

        game.current_auction = None
        game.save()

        if game.phase == GameManager.BUSINESS:
            GameManager._set_next_phase_timer(game, game.active_turn_player)
        elif game.phase == GameManager.ROLL_THE_DICES:
            GameManager._set_kick_out_timer(game, game.active_turn_player)

        game.save()

        return ResponseAuction(auction=auction)

    @classmethod
    def _next_turn(cls, game: Game, user: CustomUser) -> None:
        players_list = list(game.players.all()) 
        num_players = len(players_list)
        current_index = -1
        current_player_id = -1
        for p in players_list:
            if p == game.active_turn_player:
                current_player_id = p.pk
                current_index = game.ordered_players.index(current_player_id)
                break

        if current_index == -1:
            raise GameLogicError('current player not found')

        next_index = (current_index + 1) % num_players
        # The next active user is for both: phase and turn
        next_player = game.players.filter(pk=game.ordered_players[next_index]).first()
        if next_player is None:
            raise GameLogicError('next player is None')

        game.active_phase_player = next_player
        game.active_turn_player = next_player
        game.phase = GameManager.ROLL_THE_DICES
        game.current_turn += 1
        game.save()

        GameManager._cancel_all_timers(game)

        GameManager._set_kick_out_timer(game, next_player)

        game.save()

    @classmethod
    def _propose_trade(cls, game: Game, user: CustomUser, action: ActionTradeProposal) -> None:
        if action.player != user or action.offered_money < 0 or action.asked_money < 0:
            raise MaliciousUserInput(user, "cannot do operation")
        if action.destination_user not in game.players.all():
            # FIXME: Change to internal order so that it handles player change to AI
            raise MaliciousUserInput(user, "referenced a player that is not in game")

        asked_properties_list = action.asked_properties.all()
        asked_count = PropertyRelationship.objects.filter(
            game=game, 
            owner=action.destination_user,
            id__in=action.asked_properties.all()
        ).count()

        if asked_count != asked_properties_list.count():
            raise MaliciousUserInput(user, "destination does not have enough properties")

        offered_properties_list = action.offered_properties.all()
        offered_count = PropertyRelationship.objects.filter(
            game=game, 
            owner=action.player,
            id__in=offered_properties_list
        ).count()

        if offered_count != offered_properties_list.count():
            raise MaliciousUserInput(user, "offer does not have enough properties")

        all_trade_properties = list(asked_properties_list) + list(offered_properties_list)
        for rel in all_trade_properties:
            real_sq = rel.square.get_real_instance()
            if isinstance(real_sq, PropertySquare):
                group_has_houses = PropertyRelationship.objects.filter(
                    game=game, 
                    square__propertysquare__group=real_sq.group,
                    houses__gt=0
                ).exists()
                if group_has_houses:
                    raise MaliciousUserInput(user, "cannot trade properties from a group with constructions")

        game.phase = GameManager.PROPOSAL_ACCEPTANCE
        game.active_phase_player = action.destination_user
        game.proposal = action # type: ignore
        game.save()
        GameManager._set_next_phase_timer(game, action.destination_user)

    @classmethod
    def _bankrupt_player(cls, game: Game, user: CustomUser):
        try:
            current_idx = game.ordered_players.index(user.pk)
            next_pk = game.ordered_players[(current_idx + 1) % len(game.ordered_players)]
            next_player = CustomUser.objects.get(pk=next_pk)
        except ValueError:
            next_player = None

        if user.pk in game.ordered_players:
            game.ordered_players.remove(user.pk)

        game.players.remove(user)
        game.money.pop(str(user.pk), None)
        game.positions.pop(str(user.pk), None)
        game.jail_remaining_turns.pop(str(user.pk), None)


        PropertyRelationship.objects.filter(game=game, owner=user).delete()
        user.active_game = None
        user.save()


        if game.players.count() == 1:          
            game.phase = GameManager.END_GAME
            GameManager._cancel_all_timers(game)

            if game.current_auction:
                auction = game.current_auction
                auction.is_active = False
                auction.save()
                game.current_auction = None

            game.save()   
            return #TODO: endgame logic

        if game.active_turn_player == user and next_player:
            game.active_turn_player = next_player
            game.active_phase_player = next_player
            game.phase = GameManager.ROLL_THE_DICES
            game.streak = 0

            GameManager._cancel_all_timers(game)

            # new task
            GameManager._set_kick_out_timer(game, next_player)

        game.save()

    #TODO: llegar a fase final donde se reparte esto
    @classmethod
    def _apply_end_bonuses(cls, game: Game, num_bonuses: int = 3) -> ResponseBonus:
        all_categories = list(BonusCategory.objects.all())
        chosen = random.sample(all_categories, min(num_bonuses, len(all_categories)))

        response = ResponseBonus()
        bonuses = {}

        for category in chosen:
            field = category.stat_field
            stats = PlayerGameStatistic.objects.filter(game=game)

            max_value = stats.aggregate(Max(field))[f'{field}__max']
            if max_value and max_value > 0:
                winners = list(stats.filter(**{field: max_value}).values_list('user__pk', flat=True))
                for pk in winners:
                    game.money[str(pk)] += category.bonus_amount
            else:
                winners = []

            bonuses[str(category.pk)] = {
                'bonus_amount': category.bonus_amount,
                'winners': winners
            }

        response.bonuses = bonuses
        game.save()
        return response

    @classmethod
    def _end_game_logic(cls, game: Game, user: CustomUser, action: Action) -> Response:
        GameManager._cancel_all_timers(game)

        if not game.finished:
            game.finished = True
            response = cls._apply_end_bonuses(game, num_bonuses=3)
            response.save()
            game.bonus_response = response
            game.save()
            return response
        else:
            raise GameLogicError('game was already ended')

    @staticmethod
    def _set_next_phase_timer(game: Game, user: CustomUser):
        from .tasks import next_phase_callback, bot_play_callback
        from .celery import app

        GameManager._cancel_all_timers(game)

        task = next_phase_callback.apply_async(args=[game.pk, user.pk], countdown=50)
        game.next_phase_task_id = task.id
        game.save()

        if user.is_bot:
            bot_play_callback.apply_async(args=[game.pk, user.pk], countdown=2)

    @staticmethod
    def _set_kick_out_timer(game: Game, user: CustomUser):
        from .tasks import kick_out_callback, bot_play_callback
        from .celery import app

        if game.kick_out_task_id:
            app.control.revoke(game.kick_out_task_id, terminate=True)

        task = kick_out_callback.apply_async(args=[game.pk, user.pk], countdown=50)
        game.kick_out_task_id = task.id
        game.save()

        if user.is_bot:
            bot_play_callback.apply_async(args=[game.pk, user.pk], countdown=2)

    @staticmethod
    def _cancel_all_timers(game: Game):
        from .celery import app

        if game.next_phase_task_id:
            app.control.revoke(game.next_phase_task_id, terminate=True)
            game.next_phase_task_id = None

        if game.kick_out_task_id:
            app.control.revoke(game.kick_out_task_id, terminate=True)
            game.kick_out_task_id = None

        game.save()

    @staticmethod
    def _set_auction_timer(game: Game):
        import random
        from .tasks import auction_callback, bot_play_callback

        auction_callback.apply_async(args=[game.pk], countdown=10)

        for player in game.players.all():
            if player.is_bot:
                bot_play_callback.apply_async(args=[game.pk, player.pk], countdown=random.randint(2, 6))

process_action(game, user, action) classmethod

The only public method exposed in the API. It processes each action in dedicated functions depending on the current phase and returns a Response.

Parameters:

Name Type Description Default
game Game

The current active game instance.

required
user CustomUser

The user performing the action.

required
action Action

The deserialized action payload.

required

Returns:

Name Type Description
Response Response

A response object representing the outcome.

Raises:

Type Description
MaliciousUserInput

If the user acts out of turn or phase.

GameLogicError

If an unrecognized phase is encountered.

Source code in magnate/games.py
@classmethod
@database_sync_to_async
def process_action(cls, game: Game, user: CustomUser, action: Action) -> Response:
    """
    The only public method exposed in the API. It processes each action
    in dedicated functions depending on the current phase and returns
    a Response.

    Args:
        game (Game): The current active game instance.
        user (CustomUser): The user performing the action.
        action (Action): The deserialized action payload.

    Returns:
        Response: A response object representing the outcome.

    Raises:
        MaliciousUserInput: If the user acts out of turn or phase.
        GameLogicError: If an unrecognized phase is encountered.
    """


    if isinstance(action, ActionSurrender):
        # TODO
        cls._bankrupt_player(game, user)
        response = Response()
        return _add_basic_response_data(game, response)

    if user != game.active_phase_player and not isinstance(action, ActionBid): # if aucction there are no turns
        raise MaliciousUserInput(user, "is not the active player")



    if game.phase == cls.ROLL_THE_DICES:
        if isinstance(action, ActionPayBail):
            response = cls._pay_bail_logic(game, user, action)
        elif isinstance(action, ActionThrowDices):
            response = cls._roll_dices_logic(game, user, action)
        else:
            raise MaliciousUserInputAction(game, user, action)
    elif game.phase == cls.CHOOSE_SQUARE:
        if not isinstance(action, ActionMoveTo):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._square_chosen_logic(game, user, action)
    elif game.phase == cls.CHOOSE_FANTASY:
        if not isinstance(action, ActionChooseCard):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._choose_fantasy_logic(game, user, action)
    elif game.phase == cls.MANAGEMENT:
        if not isinstance(action, (ActionBuySquare, ActionDropPurchase, ActionTakeTram, ActionNextPhase)):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._management_logic(game, user, action)
    elif game.phase == cls.BUSINESS or game.phase == cls.LIQUIDATION:
        if not isinstance(action, (ActionBuild, ActionDemolish, ActionTradeProposal, ActionMortgageSet, ActionMortgageUnset, ActionNextPhase)):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._business_logic(game, user, action)
    elif game.phase == cls.ANSWER_TRADE_PROPOSAL:
        if not isinstance(action, ActionTradeAnswer):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._answer_trade_proposal_logic(game, user, action)
    elif game.phase == cls.AUCTION:
        if not isinstance(action, ActionBid):
            raise MaliciousUserInputAction(game, user, action)
        response = cls._bid_property_auction_logic(game, user, action)
    elif game.phase == cls.END_GAME:
        # TODO: check instance???
        # response = cls._end_game_logic(game,user,action)
        return cls._end_game_logic(game,user,action)
    else: 
        raise GameLogicError(f"Fase no reconocida o no manejada: {game.phase}")

    return _add_basic_response_data(game, response)

_pay_bail_logic(game, user, action) staticmethod

Processes the action to pay the jail bail.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The jailed user.

required
action ActionPayBail

The bail action payload.

required

Returns:

Name Type Description
Response Response

The standard response.

Raises:

Type Description
MaliciousUserInput

If the user is not actually in jail.

GameLogicError

If the user does not have enough money.

Source code in magnate/games.py
@staticmethod
def _pay_bail_logic(game: Game, user: CustomUser, action: ActionPayBail) -> Response:
    """
    Processes the action to pay the jail bail.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The jailed user.
        action (ActionPayBail): The bail action payload.

    Returns:
        Response: The standard response.

    Raises:
        MaliciousUserInput: If the user is not actually in jail.
        GameLogicError: If the user does not have enough money.
    """

    GameManager._cancel_all_timers(game)

    square = _get_user_square(game, user).get_real_instance()

    if not isinstance(square, JailSquare):
        raise MaliciousUserInput(user, "is not in jail")

    remaining = game.jail_remaining_turns.get(str(user.pk), 0)
    if remaining == 0:
        raise MaliciousUserInput(user, "is not in jail (no turns remaining)")

    bail_price = square.bail_price

    if game.money[str(user.pk)] < bail_price:
        raise GameLogicError("not enough money to pay bail")

    game.money[str(user.pk)] -= bail_price
    game.jail_remaining_turns[str(user.pk)] = 0
    game.save()

    stats = PlayerGameStatistic.objects.get(user=user,game=game)
    stats.lost_money += bail_price
    stats.save()
    # roll the dices -> continue normally
    GameManager._set_next_phase_timer(game, user)

    game.save()
    return Response()

_roll_dices_logic(game, user, action) staticmethod

Handles rolling the dice, checking for doubles/triples, and resolving jail mechanics.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The user throwing the dice.

required
action ActionThrowDices

The dice throw action payload.

required

Returns:

Name Type Description
Response Response

Standard response.

Source code in magnate/games.py
@staticmethod
def _roll_dices_logic(game: Game, user: CustomUser, action: ActionThrowDices) -> Response: 
    """
    Handles rolling the dice, checking for doubles/triples, and resolving jail mechanics.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The user throwing the dice.
        action (ActionThrowDices): The dice throw action payload.

    Returns:
        Response: Standard response.
    """

    GameManager._cancel_all_timers(game)

    response: ResponseThrowDices = ResponseThrowDices()

    d1 = random.randint(1,6)
    d2 = random.randint(1,6)
    d3 = random.randint(1,6) # 4-6 are the bus faces

    response.dice1, response.dice2, response.dice_bus = d1, d2, d3

    triples = d3 <= 3 and (d1 == d2 == d3)
    doubles = (d1 == d2) and not triples

    response.triple = triples

    current_pos_square = _get_user_square(game, user).get_real_instance()
    current_pos_id = current_pos_square.custom_id

    # jail logic
    remaining_jail_turns = game.jail_remaining_turns.get(str(user.pk), 0)
    is_jailed = remaining_jail_turns > 0

    if is_jailed:
        jail_sq = current_pos_square
        if isinstance(jail_sq, JailSquare):
            if remaining_jail_turns == 1: #obligado a salir
                game.money[str(user.pk)] -= jail_sq.bail_price
                game.jail_remaining_turns[str(user.pk)] = 0
                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.turns_in_jail += 1
                stats.lost_money += jail_sq.bail_price
                stats.save()
            elif doubles: #sale gratis
                game.jail_remaining_turns[str(user.pk)] = 0
                game.streak = 0
            else:
                # stays in jail
                game.jail_remaining_turns[str(user.pk)] -= 1
                game.phase = GameManager.BUSINESS
                game.save()
                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.turns_in_jail += 1
                stats.save()
                response.path = [current_pos_id]
                GameManager._set_next_phase_timer(game, user)
                return response
        else:
            raise GameLogicError(f"Cannot be in jail status and not in jail square")

    # Not jailed
    if triples:
        # path current -> decided in chosen
        response.triple = True
        square = current_pos_square
        all_squares = BaseSquare.objects.filter(board=square.board)
        # All squares are suitable destinations
        possible_destinations = [s.custom_id for s in all_squares]
        possible_destinations.remove(_get_jail_square().custom_id)
        game.possible_destinations = {str(c_id): 0 for c_id in possible_destinations}
        response.destinations = possible_destinations
        game.phase = GameManager.CHOOSE_SQUARE
        response.path = [current_pos_id]
        game.save()
        GameManager._set_next_phase_timer(game, user)
        return response
    elif doubles: # doubles streak only if not getting out of jail via doubles
        if game.streak >= 2:
            # path -> current and jail
            jail_square = _get_jail_square()
            response.destinations = [jail_square.custom_id]
            game.streak = 0
            response.streak = game.streak

            game.positions[str(user.pk)] = jail_square.custom_id
            game.jail_remaining_turns[str(user.pk)] = 3
            response.path = [current_pos_id, jail_square.custom_id]

            stats = PlayerGameStatistic.objects.get(user=user,game=game)
            stats.times_in_jail += 1
            stats.save()

            game.phase = GameManager.LIQUIDATION

            game.save()
            GameManager._set_next_phase_timer(game, user)
            return response
        elif not is_jailed:
            game.streak = game.streak + 1
    else:
        game.streak = 0

    response.streak = game.streak

    # Hasn't gone to jail
    dice_combinations = _compute_dice_combinations(d1, d2, d3)
    game.possible_destinations, passed_go_map = _get_possible_destinations_ids(game, user, dice_combinations)

    response.destinations = list(game.possible_destinations.keys())
    if len(game.possible_destinations) > 1:
        # path in square chosen logic
        game.phase = GameManager.CHOOSE_SQUARE
        response.path = [current_pos_id]
    else:
        stats = PlayerGameStatistic.objects.get(user=user,game=game)
        stats.walked_squares += dice_combinations[0]
        stats.save()
        dest_square_id = next(iter(game.possible_destinations))
        steps = game.possible_destinations[dest_square_id]

        # Use _move_player_logic to get the traversed path and check for "Go to Jail" or "Passing Go".
        move_result = _move_player_logic(current_pos_square, steps)
        response.path = move_result["path"]

        if move_result["jailed"]:
            # landing in go to jail; update state to jail the player.
            jail = JailSquare.objects.first()
            if jail is None:
                raise GameDesignError('no jail in game')

            game.positions[str(user.pk)] = jail.custom_id
            game.jail_remaining_turns[str(user.pk)] = 3
            game.phase = GameManager.LIQUIDATION
            stats = PlayerGameStatistic.objects.get(user=user, game=game)
            stats.times_in_jail += 1
            stats.save()
        else:
            game.positions[str(user.pk)] = dest_square_id
            square = _get_square_by_custom_id(dest_square_id)
            _apply_square_arrival(game, user, response, square, move_result["passed_go"])


    game.save()
    GameManager._set_next_phase_timer(game, user)
    return response

_square_chosen_logic(game, user, action) staticmethod

Handles the logic when a user selects their final destination (if multiple choices were given).

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The current user.

required
action ActionMoveTo

Action indicating the chosen square.

required

Returns:

Name Type Description
Response Response

Standard response.

Raises:

Type Description
MaliciousUserInput

If the chosen square is not in possible_destinations.

Source code in magnate/games.py
@staticmethod
def _square_chosen_logic(game: Game, user: CustomUser, action: Action) -> Response:
    """
    Handles the logic when a user selects their final destination (if multiple choices were given).

    Args:
        game (Game): The current game instance.
        user (CustomUser): The current user.
        action (ActionMoveTo): Action indicating the chosen square.

    Returns:
        Response: Standard response.

    Raises:
        MaliciousUserInput: If the chosen square is not in `possible_destinations`.
    """

    GameManager._cancel_all_timers(game)
    response: ResponseChooseSquare = ResponseChooseSquare()

    if not isinstance(action, ActionMoveTo):
        raise MaliciousUserInputAction(game, user, action)

    square = action.square

    if str(square.custom_id) not in game.possible_destinations:
        raise MaliciousUserInput(user, "tried to move to an illegal square")

    current_pos_id = game.positions[str(user.pk)]
    current_pos_square = _get_square_by_custom_id(current_pos_id).get_real_instance()

    steps = game.possible_destinations.get(str(square.custom_id))

    move_result = _move_player_logic(current_pos_square, steps)

    response.path = move_result["path"]

    if move_result["jailed"]:
        # landing in go to jail; update state to jail the player.
        jail = JailSquare.objects.first()
        if jail is None:
            raise GameDesignError('no jail in game')

        game.positions[str(user.pk)] = jail.custom_id
        game.jail_remaining_turns[str(user.pk)] = 3
        game.phase = GameManager.LIQUIDATION
        stats = PlayerGameStatistic.objects.get(user=user, game=game)
        stats.times_in_jail += 1
        stats.save()
    else:
        game.positions[str(user.pk)] = square.custom_id
        square = _get_square_by_custom_id(square.custom_id)
        _apply_square_arrival(game, user, response, square, move_result["passed_go"])

    stats = PlayerGameStatistic.objects.get(user=user,game=game)
    stats.walked_squares += steps
    stats.save()

    game.possible_destinations = dict()
    game.save()

    GameManager._set_next_phase_timer(game, user)
    return response

_management_logic(game, user, action) staticmethod

Logic of management phase, where the user can buy properties, pay bills etc.

It checks the user's action against the current square they are on to update the game state and transition to the next phase.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The acting user.

required
action Action

The payload of the action taken (e.g., ActionBuySquare).

required

Returns:

Name Type Description
Response Response

Standard response.

Raises:

Type Description
MaliciousUserInputAction

If the action does not fit the phase/context.

Source code in magnate/games.py
@staticmethod
def _management_logic(game: Game, user: CustomUser, action: Action) -> Response:
    """
    Logic of management phase, where the user can buy properties, pay bills etc.

    It checks the user's action against the current square they are on to update 
    the game state and transition to the next phase.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The acting user.
        action (Action): The payload of the action taken (e.g., ActionBuySquare).

    Returns:
        Response: Standard response.

    Raises:
        MaliciousUserInputAction: If the action does not fit the phase/context.
    """
    GameManager._cancel_all_timers(game)

    current_square = _get_user_square(game, user).get_real_instance()
    prop_rel = _get_relationship(game, current_square)

    if isinstance(action, ActionBuySquare):
        if isinstance(current_square, PropertySquare):
            # TODO: Check money
            game.money[str(user.pk)] -= current_square.buy_price
            stats = PlayerGameStatistic.objects.get(user=user,game=game)
            stats.lost_money += current_square.buy_price
            stats.save()
            new_property = PropertyRelationship(game=game, square=current_square, owner=user)
            user_properties = PropertyRelationship.objects.filter(game=game, owner=user)
            user_same_group_properties = user_properties.filter(square__propertysquare__group=current_square.group)

            group_squares = PropertySquare.objects.filter(group=current_square.group, board = current_square.board)

            if user_same_group_properties.count() == group_squares.count() - 1:
                new_property.houses = 0
                user_same_group_properties.update(houses=0)
            else: 
                new_property.houses = -1
            new_property.save()

        elif isinstance(current_square, ServerSquare):
            game.money[str(user.pk)] -= current_square.buy_price
            stats = PlayerGameStatistic.objects.get(user=user,game=game)
            stats.lost_money += current_square.buy_price
            stats.save()
            new_property = PropertyRelationship(game=game, square=current_square, owner=user)
            new_property.save()

        elif isinstance(current_square, BridgeSquare):
            game.money[str(user.pk)] -= current_square.buy_price
            stats = PlayerGameStatistic.objects.get(user=user, game=game)
            stats.lost_money += current_square.buy_price
            stats.save()
            new_property = PropertyRelationship(game=game, square=current_square, owner=user)
            new_property.save()

        else:
            raise MaliciousUserInputAction(game, user, action)
    elif isinstance(action, ActionDropPurchase):
        if isinstance(action.square, (PropertySquare, ServerSquare, BridgeSquare)):
            return GameManager._initiate_auction(game, action.square)
        else:
            raise MaliciousUserInputAction(game, user, action)
    elif isinstance(action, ActionTakeTram):
        if isinstance(current_square, TramSquare):
            square = action.square
            tram_squares = TramSquare.objects.filter()
            tram_square_actual_id = game.positions[str(user.pk)]
            tram_squares_extern_ids = [s.custom_id for s in tram_squares]

            if square.custom_id == tram_square_actual_id: # case stay in the same square, free
                pass
            elif square.custom_id in tram_squares_extern_ids: # Move to another square
                if game.money[str(user.pk)] < square.buy_price:
                    raise MaliciousUserInput(user, "does not have enough money to take tram")
                game.money[str(user.pk)] -= square.buy_price
                stats = PlayerGameStatistic.objects.get(user=user,game=game)
                stats.lost_money += square.buy_price
                stats.save()
                game.positions[str(user.pk)] = square.custom_id
            else:
                raise MaliciousUserInput(user, "tried to take a tram to a non tram square")
        else:
            raise MaliciousUserInputAction(game, user, action)
    elif isinstance(action, ActionNextPhase):
        # TODO: Remove?
        pass
    else:
        raise MaliciousUserInputAction(game, user, action)

    if game.phase == GameManager.MANAGEMENT:
        if game.streak == 0:
            game.phase = GameManager.BUSINESS
        else:
            game.phase = GameManager.ROLL_THE_DICES

    if game.phase == GameManager.BUSINESS:
        GameManager._set_next_phase_timer(game, user)
    elif game.phase == GameManager.ROLL_THE_DICES:
        GameManager._set_kick_out_timer(game, user)

    game.save()
    return Response()

_business_logic(game, user, action) staticmethod

Unifies the business and liquidation phases. Handles building, demolishing, trading, mortgaging, and proceeding to the next turn.

Parameters:

Name Type Description Default
game Game

The current game instance.

required
user CustomUser

The active user.

required
action Action

The payload of the action taken.

required

Returns:

Name Type Description
Response Response

Standard response.

Raises:

Type Description
MaliciousUserInput

If restrictions (e.g., negative balance on next turn) are unmet.

Source code in magnate/games.py
@staticmethod
def _business_logic(game: Game, user: CustomUser, action: Action) -> Response:
    """
    Unifies the business and liquidation phases. Handles building, demolishing, 
    trading, mortgaging, and proceeding to the next turn.

    Args:
        game (Game): The current game instance.
        user (CustomUser): The active user.
        action (Action): The payload of the action taken.

    Returns:
        Response: Standard response.

    Raises:
        MaliciousUserInput: If restrictions (e.g., negative balance on next turn) are unmet.
    """

    GameManager._cancel_all_timers(game)
    # Logic for the business phase where players can build houses, trade, etc.
    if isinstance(action, ActionBuild):
        # Check owner 
        building_square = action.square
        relationship = _build_square(game, user, building_square, action.houses, False)

    elif isinstance(action, ActionDemolish):
        # Similiar to build
        demolition_square = action.square
        relationship = _demolish_square(game, user, demolition_square, action.houses, False)

    elif isinstance(action, ActionTradeProposal):
        relationship = GameManager._propose_trade(game, user, action)

    elif isinstance(action, ActionMortgageSet):
        relationship = _set_mortgage(game, user, action.square, False)

    elif isinstance(action, ActionMortgageUnset):
        relationship = _unset_mortgage(game, user, action.square, False)        
    elif isinstance(action, ActionNextPhase):
        current_money = game.money[str(user.pk)]


        if game.phase == GameManager.BUSINESS:
            if current_money >= 0:
                GameManager._next_turn(game, user)
            else:
                game.phase = GameManager.LIQUIDATION
                game.save()

        elif game.phase == GameManager.LIQUIDATION:
            if current_money >= 0:
                GameManager._next_turn(game, user)
            else:
                raise MaliciousUserInput(user, "Cannot end in NEGATIVE")
        return Response() # timers set in next turn

    GameManager._set_next_phase_timer(game, user)
    return Response()