davidcr01 / WordlePlus

Repository to store all the documentation, files and structure of my Final Degree Project (TFG in Spanish). The main goal is to develop, as full-stack web developer, a remodel of the Wordle game, including more features and functionalities using Ionic, Django REST Api and PostgreSQL.
1 stars 0 forks source link

Join a tournament (S9) #49

Closed davidcr01 closed 1 year ago

davidcr01 commented 1 year ago

Description

As a player, is necessary to implement the logic and UI to join a tournament.

Notice that, when the tournament is closed, is necessary to create the associate

Tasks

davidcr01 commented 1 year ago

Update Report

To manage the game of the tournaments, it is necessary to implement a new method in the Game viewset. This is because in the tournaments the games are created automatically, and not by a player. Because of this, the POST and PATCH method executed by the player1 and player2 respectively, implemented in #50 is not useful.

A new patch method has been added:

def partial_update_tournament(self, request, *args, **kwargs):
        instance = self.get_object()
        player = getattr(request.user, 'player', None)
        if not player:
            return Response({'error': 'Player not found'}, status=404)

        if player == instance.player1:
            if instance.player1_xp != 0 or instance.player1_time != 0:
                return Response({'error': 'You have already updated your information for this game.'}, status=400)
            opponent = instance.player2
            opponent_xp = instance.player2_xp
            opponent_time = instance.player2_time
        elif player == instance.player2:
            if instance.player2_xp != 0 or instance.player2_time != 0:
                return Response({'error': 'You have already updated your information for this game.'}, status=400)
            opponent = instance.player1
            opponent_xp = instance.player1_xp
            opponent_time = instance.player1_time
        else:
            return Response({'error': 'You do not have permission to update this game.'}, status=403)

        # Data requested change depending on the player who is executing the method
        if instance.player1 == player:
            allowed_fields = ['player1_xp', 'player1_time', 'player1_attempts', 'word']
        elif instance.player2 == player:
            allowed_fields = ['player2_xp', 'player2_time', 'player2_attempts', 'word']
        else:
            return Response({'error': 'You do not have permission to update this game.'}, status=403)

        data = {key: request.data.get(key) for key in allowed_fields}
        if 'winner' in data:
            return Response({'error': 'The "winner" field cannot be modified.'}, status=400)

        if instance.word != '':
            data.pop('word')

        serializer = self.get_serializer(instance, data=data, partial=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        player_xp = serializer.validated_data.get('player1_xp' if player == instance.player1 else 'player2_xp', 0)
        player_time = serializer.validated_data.get('player1_time' if player == instance.player1 else 'player2_time', 0)

        # Winner is calculated when all data is available
        if (player_xp != 0 and player_time != 0 and opponent_xp != 0 and opponent_time != 0):
            if player_xp > opponent_xp:
                instance.winner = player
                instance.winner.wins_pvp += 1
                instance.winner.save()
                instance.save()
            elif player_xp < opponent_xp:
                instance.winner = opponent
                opponent.wins_pvp += 1
                instance.winner.save()
                instance.save()
            else:
                if player_time <= opponent_time:
                    instance.winner = player
                    instance.winner.wins_pvp += 1
                    instance.winner.save()
                    instance.save()
                else:
                    instance.winner = opponent
                    opponent.wins_pvp += 1
                    instance.winner.save()
                    instance.save()

        if instance.winner is not None:
            return Response({'winner': instance.winner.user.username})
        else:
            return Response({'message': 'Game updated successfully.'})

This method is executed by both players. Every player fills its information, and when all the information is stored, the winner is calculated. Some error cases are checked:

Player2 fills its information: image

Player1 fills its information: image

And the winner is set. Notice that the word 'beach' passed in the second patch was ignored. image

davidcr01 commented 1 year ago

Update Report - Backend

To automatically create the rounds and games when the tournament is closed. To do this, is necessary to edit the ParticipationViewSet, in the part when the tournament is closed:

participation = Participation.objects.create(tournament=tournament, player=player)
        tournament.num_players += 1
        # Close the tournament if is full
        if tournament.num_players >= tournament.max_players:
            tournament.is_closed = True

            rounds = int(math.log2(tournament.max_players))
            for round_number in range(1, rounds+1):
                round = Round.objects.create(tournament=tournament, number=round_number)
                if round_number == 1:
                    # Assign games to the first round
                    participants = Participation.objects.filter(tournament=tournament)
                    participants_count = participants.count()
                    for i in range(0, participants_count, 2):
                        player1 = participants[i].player
                        player2 = participants[i + 1].player
                        new_game = Game.objects.create(player1=player1, player2=player2, is_tournament_game=True)
                        RoundGame.objects.create(round=round, game=new_game)

This code snippet creates all the rounds of the tournament, knowing the max number of participants; and creates the related games to the first round.

Same code has been added to the admin.py file to manage this logic when an administrator creates a pre-selected tournament.

Now, is necessary to create a new logic that creates the games of the next round when the current round is over (when all the related games are completed). To do this, a new signal has been added to the signals.py. This signal is executed everytime a tournament game is finished:

@receiver(post_save, sender=Game)
def game_completed(sender, instance, created, **kwargs):
    if instance.is_tournament_game and instance.winner:
        round = Round.objects.filter(roundgame__game=instance).last()
        if round:
            round_games = RoundGame.objects.filter(round=round)
            if all(game.game.winner for game in round_games):
                tournament = round.tournament
                # Get current round
                current_round_number = tournament.current_round
                num_rounds = int(math.log2(tournament.max_players))

                # Get winners of current round
                winners = [game.game.winner for game in round_games]

                # If its the last round, games are not created (tournament end)
                if current_round_number == num_rounds:
                    winner = instance.winner
                    winner.wins_tournament += 1
                    winner.xp += 1000
                    winner.save()
                else:
                    # Get next round
                    next_round_number = current_round_number + 1
                    next_round = Round.objects.get(tournament=tournament, number=next_round_number)
                    tournament.current_round = next_round_number
                    tournament.save()

                    # Create games and assign them to the round
                    for i in range(0, len(winners), 2):
                        player1 = winners[i]
                        player2 = winners[i + 1]
                        new_game = Game.objects.create(player1=player1, player2=player2, is_tournament_game=True)
                        RoundGame.objects.create(round=next_round, game=new_game)

This code creates the next round with the winners of the previous round if the round is not the last one. If it is, the winner is fetched and updated.

Screenshot of a tournament of 4 participants. Notice how the round 1 is created and the round 2 contains the winners of the round 1. image

When the round 2 is completed (last one), no round is created and the user is updated. image image

To fetch this information, new methods in the TournamentViewSet have been added:

Returns the rounds of the tournament image

Returns the games of the round by number image

Update Report - Frontend

The mockup for the tournament rounds is: image

To allow the user join a tournament, a new function in the tournament page has been added:

async joinTournament(idTournament: number) {
    (await this.apiService.createParticipation(idTournament)).subscribe(
      (response) => {
        this.toastService.showToast('You joined the tournament successfully!', 2000, 'top', 'success');
        console.log('Participation added successfully', response);
      },
      (error) => {
        if (error.status === 400) {
          this.toastService.showToast('You already participate in this tournament!', 2000, 'top', 'warning');
        } else {
          this.toastService.showToast('There was an error jonining to the tournament!', 2000, 'top', 'danger');
        }
        console.log('Participation could not be added', error);
    }
    );
  }

      <ion-button expand="block" class="join-button" [disabled]="tournament.is_closed" (click)="joinTournament(tournament.id)">Join</ion-button>

With this, the participation is created and the user has joined the tournament.

A new segment in the tournament page has been added to display the tournaments that the player is joined:

image This segment uses a new function getMyTournaments of the apiService. The related view is:

@action(detail=False, methods=['get'])
    def player_tournaments(self, request):
        player = getattr(request.user, 'player', None)
        if not player:
            return Response({'error': 'Player not found'}, status=404)

        tournaments = Tournament.objects.filter(participation__player=player).order_by('-is_closed')
        serializer = TournamentSerializer(tournaments, many=True)
        return Response(serializer.data)

When the user enters into a tournament, a new page tournamentrounds is displayed. This page contains the games of every round of the tournament:

image

Notice that:

To display this information, new methods have been added to the apiService: getRounds, getGameRounds, y getTournamentInfo. The related methods are:

 async loadTournamentInfo() {
    this.isLoading = true;
    (await this.apiService.getTournamentInfo(this.tournamentId)).subscribe(
      (response) => {
        console.log(response);
        console.log(response.current_round);
        this.tournamentInfo = response;
        this.currentRound = response.current_round;
        this.selectedSegment = response.current_round;
      },
      (error) => {
        console.error('Error loading tournament info:', error);
        this.isLoading = true;
      }
    );
  }

  async loadTournamentRounds() {
    this.isLoading = true;
    (await this.apiService.getRounds(this.tournamentId)).subscribe(
      (data: any) => {
        this.rounds = data;
        this.loadRoundGames(this.selectedSegment);
      },
      (error) => {
        console.error('Error loading tournament rounds:', error);
        this.isLoading = false;
      }
    );
  }

  async loadRoundGames(roundNumber: number) {
    this.isLoading = true;
    (await this.apiService.getGamesRound(this.tournamentId, roundNumber)).subscribe(
      (data: any) => {
        console.log(data);
        this.roundGames = data;
        this.isLoading = false;
      },
      (error) => {
        console.error('Error loading round games:', error);
        this.isLoading = false;
      }
    );
  }

Every time a segment is clicked, the games related to the round selected are loaded.

When the last round is completed, the username of the tournament winner is displayed: image

This is implemented by modifying the loadroundGames:

if (roundNumber === this.lastRound && this.roundGames[0].winner) {
          (await this.apiService.getPlayerData(this.roundGames[0].winner)).subscribe(
            (data: any) => {
              this.winnerTournamentUsername = data.user.username;
            },
            (error) => {
              console.error('Error loading winner username:', error);
              this.isLoading = false;
            }
          );
        }
davidcr01 commented 1 year ago

Update Report - Playing the tournament

As the tournament games are different from the multiplayer games (the tournament games are already created, they are not created by any player), is necessary to implement a new page with a new logic to play the tournament games game-tournament.

The HTML code is very similar to respond-game:

<ion-header>
  <ion-toolbar *ngIf="tournamentInfo">
    <ion-title>Game: {{ tournamentInfo.name }}</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ng-container *ngIf="player1 && player2">
  <div class="game-info">
    <div class="username">{{ player1 }}</div>
    <img src="../../../assets/icon/vs.png" alt="VS icon">
    <div class="username">{{ player2 }}</div>
  </div>
  </ng-container>

  <ng-container *ngIf="wordLength && !selectedWord">
    <app-wordle-dashboard [WORDS_LENGTH]="wordLength" [isMultiplayer]="true" (gameFinished)="finishGame($event.time, $event.xp, $event.attempts, $event.selectedWord)"></app-wordle-dashboard>
  </ng-container>
  <ng-container *ngIf="wordLength && selectedWord">
    <app-wordle-dashboard [rightGuessString]="selectedWord" [WORDS_LENGTH]="wordLength" [isMultiplayer]="true" (gameFinished)="finishGame($event.time, $event.xp, $event.attempts, $event.selectedWord)"></app-wordle-dashboard>
  </ng-container>
</ion-content>

Notice that the selectedWordmay be passed if the game already has a word assigned. If not, the word will be selected by the selectRandomWord of the Wordle Dashboard component.

In this case, both players use the same API method, but with different bodies:

async finishGame(time: number, xp: number, attempts: number, selectedWord: string) {
    let gameData: any = {};

    gameData = {
      player1_time: time,
      player1_xp: xp,
      player1_attempts: attempts,
      word: selectedWord,
    };

    if (this.selfUsername === this.player2) {
      gameData = {
        player2_time: time,
        player2_xp: xp,
        player2_attempts: attempts,
        word: selectedWord,
      };
    }

    (await this.apiService.resolveTournamentGame(this.gameId, gameData)).subscribe(
      (response) => {
        console.log('Game resolved successfully', response);
        if (response.message) {
          setTimeout( () => this.showAlert('Amazing!', 'Who will win?'), 2500);
        }
        else {
          if (response.winner === this.selfUsername) {
            setTimeout( () => this.showAlert('Congratulations!', 'You won! You will be in the next round!'), 2500);
          } else {
            setTimeout( () => this.showAlert('Bad news!', 'You lost. Try next time!'), 2500);
          }
        }
      },
      (error) => {
        console.log('Game could not be resolved', error);
      }
    );
  }

Same conditions errors are checked as create-game and respond-game