From 26de57fbfc1a61e88e617324701b8a65cba950a4 Mon Sep 17 00:00:00 2001 From: Michel Wilhelm Date: Sat, 7 Nov 2020 00:29:51 -0300 Subject: [PATCH] Creating structure for the game events (#3) * Adding the GameVent model and migration * Initializing the GameEvent endpoint * GameEvent listing endpoint * Adding CLICK_NAIVE event * Added signal for identify what is on the click location * Using row and col integer is better than metadata with jsonfield * Create event for the same position is not allowed * Better signals control * Adding a board progress to Game model * Identifying the win status * Hide generated board from client --- api/resources/game.py | 41 +++++++++- api/serializers/__init__.py | 4 +- api/serializers/game.py | 8 +- api/urls.py | 3 +- game/__init__.py | 1 + game/admin.py | 29 +++++++ game/apps.py | 3 + game/game.py | 40 +++++++++- game/migrations/0003_gameevent.py | 73 +++++++++++++++++ game/migrations/0004_auto_20201106_0453.py | 41 ++++++++++ game/migrations/0005_auto_20201106_2357.py | 51 ++++++++++++ game/migrations/0006_auto_20201107_0010.py | 19 +++++ game/migrations/0007_auto_20201107_0130.py | 33 ++++++++ game/models.py | 70 ++++++++++++++-- game/signals.py | 92 ++++++++++++++++++++++ 15 files changed, 494 insertions(+), 14 deletions(-) create mode 100644 game/migrations/0003_gameevent.py create mode 100644 game/migrations/0004_auto_20201106_0453.py create mode 100644 game/migrations/0005_auto_20201106_2357.py create mode 100644 game/migrations/0006_auto_20201107_0010.py create mode 100644 game/migrations/0007_auto_20201107_0130.py create mode 100644 game/signals.py diff --git a/api/resources/game.py b/api/resources/game.py index c51ac25..ac703e6 100644 --- a/api/resources/game.py +++ b/api/resources/game.py @@ -2,8 +2,8 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from game.models import Game -from ..serializers import GameSerializer +from game.models import Game, GameEvent, GameStatuses +from ..serializers import GameSerializer, GameEventSerializer class GameResource(APIView): @@ -28,3 +28,40 @@ class GameSingleResource(APIView): serializer = GameSerializer(game) return Response(serializer.data, status=status.HTTP_200_OK) + + +class GameEventResource(APIView): + def get(self, request, game_id): + """ Returns a list of all game events """ + events = GameEvent.objects.filter(game_id=game_id) + serializer = GameEventSerializer(events, many=True) + return Response(serializer.data) + + def post(self, request, game_id): + """ Creates a new event """ + + try: + game = Game.objects.get(pk=game_id) + except Game.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if game.status == GameStatuses.FINISHED: + return Response( + {"message": "Game is already finished"}, + status=status.HTTP_412_PRECONDITION_FAILED, + ) + + row = request.data.get("row") + col = request.data.get("col") + game_event = GameEvent.objects.filter(game=game, row=row, col=col).first() + if game_event: + return Response( + {"message": "This event was already registered"}, + status=status.HTTP_409_CONFLICT, + ) + + serializer = GameEventSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/api/serializers/__init__.py b/api/serializers/__init__.py index 964a753..8872be8 100644 --- a/api/serializers/__init__.py +++ b/api/serializers/__init__.py @@ -1,4 +1,4 @@ -from .game import GameSerializer +from .game import GameSerializer, GameEventSerializer -__all__ = ["GameSerializer"] +__all__ = ["GameSerializer", "GameEventSerializer"] diff --git a/api/serializers/game.py b/api/serializers/game.py index 6b61620..5d5a717 100644 --- a/api/serializers/game.py +++ b/api/serializers/game.py @@ -1,9 +1,15 @@ from rest_framework import serializers -from game.models import Game +from game.models import Game, GameEvent class GameSerializer(serializers.ModelSerializer): class Meta: model = Game + exclude = ["board"] + + +class GameEventSerializer(serializers.ModelSerializer): + class Meta: + model = GameEvent fields = "__all__" diff --git a/api/urls.py b/api/urls.py index a1ff5ac..922fcaf 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,12 +1,13 @@ from django.urls import path from .resources.main import MainResource -from .resources.game import GameResource, GameSingleResource +from .resources.game import GameResource, GameSingleResource, GameEventResource app_name = "api" urlpatterns = [ + path("games//events", GameEventResource.as_view(), name="games_events"), path("games/", GameSingleResource.as_view(), name="games_single"), path("games", GameResource.as_view(), name="games"), path("", MainResource.as_view(), name="main"), diff --git a/game/__init__.py b/game/__init__.py index e69de29..6d8f478 100644 --- a/game/__init__.py +++ b/game/__init__.py @@ -0,0 +1 @@ +default_app_config = "game.apps.GameConfig" diff --git a/game/admin.py b/game/admin.py index e69de29..f83268e 100644 --- a/game/admin.py +++ b/game/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin + +from .models import Game, GameEvent + + +@admin.register(Game) +class GameAdmin(admin.ModelAdmin): + list_display = ( + "id", + "created_at", + "modified_at", + "rows", + "cols", + "mines", + "win", + "status", + ) + + list_filter = ( + "win", + "status", + ) + + +@admin.register(GameEvent) +class GameEventAdmin(admin.ModelAdmin): + list_display = ("id", "created_at", "game", "type", "row", "col") + + list_filter = ("type",) diff --git a/game/apps.py b/game/apps.py index 6198c24..d056f90 100644 --- a/game/apps.py +++ b/game/apps.py @@ -5,3 +5,6 @@ class GameConfig(AppConfig): name = "game" verbose_name = "Game" verbose_name_plural = "Games" + + def ready(self): + import game.signals # noqa diff --git a/game/game.py b/game/game.py index ed5f5c2..02b98a5 100644 --- a/game/game.py +++ b/game/game.py @@ -4,14 +4,23 @@ import random class Minesweeper: board = [] - def __init__(self, rows=10, cols=10, mines=5): + def __init__(self, rows=10, cols=10, mines=5, board=None, board_progress=None): self.rows = rows self.cols = cols self.mines = mines + if board is not None: + self.board = board + + if board_progress is not None: + self.board_progress = board_progress + def create_board(self): """ Creating the board cells with 0 as default value """ self.board = [[0 for col in range(self.cols)] for row in range(self.rows)] + self.board_progress = [ + ["-" for col in range(self.cols)] for row in range(self.rows) + ] def put_mine(self): """Put a single mine on the board. @@ -62,12 +71,26 @@ class Minesweeper: self.increment_safe_point(mine_position_row - 1, mine_position_col - 1) def is_mine(self, row, col): - """ Checks whether the given location is a mine or not """ + """ Checks whether the given location have a mine """ try: return self.board[row][col] == -1 except IndexError: return False + def is_empty(self, row, col): + """ Checks whether the given location is empty """ + try: + return self.board[row][col] == 0 + except IndexError: + return False + + def is_point(self, row, col): + """ Checks whether the given location have pontuation """ + try: + return self.board[row][col] > 0 + except IndexError: + return False + def is_point_in_board(self, row, col): """ Checks whether the location is inside board """ if row in range(0, self.rows) and col in range(0, self.cols): @@ -87,3 +110,16 @@ class Minesweeper: # Increment the value of the position becaus is close to some mine self.board[row][col] += 1 + + def reveal(self, row, col): + self.board_progress[row][col] = self.board[row][col] + + def win(self): + """ Identify if the player won the game """ + unrevealed = 0 + for row in self.board_progress: + for cell in row: + if cell == "-": + unrevealed += 1 + if (unrevealed - self.mines) == 0: + return True diff --git a/game/migrations/0003_gameevent.py b/game/migrations/0003_gameevent.py new file mode 100644 index 0000000..14cfeae --- /dev/null +++ b/game/migrations/0003_gameevent.py @@ -0,0 +1,73 @@ +# Generated by Django 3.1.3 on 2020-11-06 03:54 + +from django.db import migrations, models +import django.db.models.deletion +import django_mysql.models +import game.models +import internal.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0002_auto_20201106_0225"), + ] + + operations = [ + migrations.CreateModel( + name="GameEvent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation date" + ), + ), + ( + "type", + models.IntegerField( + choices=[ + (0, "START_GAME"), + (1, "PAUSE"), + (2, "RESUME"), + (3, "CLICK_MINE"), + (4, "CLICK_POINT"), + (5, "CLICK_EMPTY"), + (6, "CLICK_FLAG"), + (7, "GAME_OVER"), + ], + default=game.models.EventTypes["START_GAME"], + help_text="The game event", + ), + ), + ( + "metadata", + django_mysql.models.JSONField( + default=internal.utils.empty_object, + help_text="Some usefull event metadata", + verbose_name="Event metadata", + ), + ), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="game.game" + ), + ), + ], + options={ + "verbose_name": "Game event", + "verbose_name_plural": "Game events", + "db_table": "game_events", + }, + ), + ] diff --git a/game/migrations/0004_auto_20201106_0453.py b/game/migrations/0004_auto_20201106_0453.py new file mode 100644 index 0000000..e391011 --- /dev/null +++ b/game/migrations/0004_auto_20201106_0453.py @@ -0,0 +1,41 @@ +# Generated by Django 3.1.3 on 2020-11-06 04:53 + +from django.db import migrations, models +import game.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0003_gameevent"), + ] + + operations = [ + migrations.AlterModelOptions( + name="gameevent", + options={ + "ordering": ["created_at"], + "verbose_name": "Game event", + "verbose_name_plural": "Game events", + }, + ), + migrations.AlterField( + model_name="gameevent", + name="type", + field=models.IntegerField( + choices=[ + (0, "START_GAME"), + (1, "PAUSE"), + (2, "RESUME"), + (3, "CLICK_MINE"), + (4, "CLICK_POINT"), + (5, "CLICK_EMPTY"), + (6, "CLICK_FLAG"), + (7, "GAME_OVER"), + (8, "CLICK_NAIVE"), + ], + default=game.models.EventTypes["START_GAME"], + help_text="The game event", + ), + ), + ] diff --git a/game/migrations/0005_auto_20201106_2357.py b/game/migrations/0005_auto_20201106_2357.py new file mode 100644 index 0000000..83e1568 --- /dev/null +++ b/game/migrations/0005_auto_20201106_2357.py @@ -0,0 +1,51 @@ +# Generated by Django 3.1.3 on 2020-11-06 23:57 + +from django.db import migrations, models +import game.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0004_auto_20201106_0453"), + ] + + operations = [ + migrations.RemoveField(model_name="gameevent", name="metadata",), + migrations.AddField( + model_name="gameevent", + name="event_col", + field=models.PositiveIntegerField( + blank=True, + default=None, + help_text="Column on the board where the event occurred, if applicable", + null=True, + verbose_name="The column clicked", + ), + ), + migrations.AddField( + model_name="gameevent", + name="event_row", + field=models.PositiveIntegerField( + blank=True, + default=None, + help_text="Row on the board where the event occurred, if applicable", + null=True, + verbose_name="The row clicked", + ), + ), + migrations.AlterField( + model_name="game", + name="status", + field=models.IntegerField( + choices=[ + (0, "NOT_PLAYED"), + (1, "PLAYING"), + (2, "PAUSED"), + (3, "FINISHED"), + ], + default=game.models.GameStatuses["NOT_PLAYED"], + help_text="Actual game status", + ), + ), + ] diff --git a/game/migrations/0006_auto_20201107_0010.py b/game/migrations/0006_auto_20201107_0010.py new file mode 100644 index 0000000..7b2dc25 --- /dev/null +++ b/game/migrations/0006_auto_20201107_0010.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.3 on 2020-11-07 00:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0005_auto_20201106_2357"), + ] + + operations = [ + migrations.RenameField( + model_name="gameevent", old_name="event_col", new_name="col", + ), + migrations.RenameField( + model_name="gameevent", old_name="event_row", new_name="row", + ), + ] diff --git a/game/migrations/0007_auto_20201107_0130.py b/game/migrations/0007_auto_20201107_0130.py new file mode 100644 index 0000000..056e56f --- /dev/null +++ b/game/migrations/0007_auto_20201107_0130.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.3 on 2020-11-07 01:30 + +from django.db import migrations +import django_mysql.models +import internal.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0006_auto_20201107_0010"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="board_progress", + field=django_mysql.models.JSONField( + default=internal.utils.empty_list, + help_text="This board is updated at each GameEvent recorded", + verbose_name="Progress board", + ), + ), + migrations.AlterField( + model_name="game", + name="board", + field=django_mysql.models.JSONField( + default=internal.utils.empty_list, + help_text="The generated board game", + verbose_name="Generated board", + ), + ), + ] diff --git a/game/models.py b/game/models.py index 81c30d3..7def6f9 100644 --- a/game/models.py +++ b/game/models.py @@ -6,18 +6,23 @@ from internal.utils import empty_list from .game import Minesweeper -class GameStatuses(IntEnum): +class EnumChoicesBase(IntEnum): """ Enum was used as choices of Game.status because explicit is better than implicit """ - NOT_PLAYED = 0 - PLAYING = 1 - FINISHED = 2 - @classmethod def choices(cls): return [(key.value, key.name) for key in cls] +class GameStatuses(EnumChoicesBase): + """ Statuses used by the player and system on game """ + + NOT_PLAYED = 0 + PLAYING = 1 + PAUSED = 2 + FINISHED = 3 + + class Game(models.Model): created_at = models.DateTimeField("Creation date", auto_now_add=True) modified_at = models.DateTimeField("Last update", auto_now=True) @@ -33,8 +38,14 @@ class Game(models.Model): ) board = JSONField( - "Generated board", default=empty_list, help_text="Whe generated board game" + "Generated board", default=empty_list, help_text="The generated board game" ) + board_progress = JSONField( + "Progress board", + default=empty_list, + help_text="This board is updated at each GameEvent recorded", + ) + win = models.BooleanField( "Win?", default=None, @@ -56,6 +67,7 @@ class Game(models.Model): ms.create_board() ms.put_mines() self.board = ms.board + self.board_progress = ms.board_progress super(Game, self).save(*args, **kwargs) @@ -63,3 +75,49 @@ class Game(models.Model): verbose_name = "Game" verbose_name_plural = "Games" db_table = "games" + + +class EventTypes(EnumChoicesBase): + """ Event types to generate a game timeline """ + + START_GAME = 0 + PAUSE = 1 + RESUME = 2 + CLICK_MINE = 3 + CLICK_POINT = 4 + CLICK_EMPTY = 5 + CLICK_FLAG = 6 + GAME_OVER = 7 + CLICK_NAIVE = 8 + + +class GameEvent(models.Model): + created_at = models.DateTimeField("Creation date", auto_now_add=True) + game = models.ForeignKey("game.Game", on_delete=models.CASCADE) + + type = models.IntegerField( + choices=EventTypes.choices(), + default=EventTypes.START_GAME, + help_text="The game event", + ) + + row = models.PositiveIntegerField( + "The row clicked", + default=None, + null=True, + blank=True, + help_text="Row on the board where the event occurred, if applicable", + ) + col = models.PositiveIntegerField( + "The column clicked", + default=None, + null=True, + blank=True, + help_text="Column on the board where the event occurred, if applicable", + ) + + class Meta: + ordering = ["created_at"] + verbose_name = "Game event" + verbose_name_plural = "Game events" + db_table = "game_events" diff --git a/game/signals.py b/game/signals.py new file mode 100644 index 0000000..f1c1d7a --- /dev/null +++ b/game/signals.py @@ -0,0 +1,92 @@ +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from .models import Game, GameEvent, EventTypes, GameStatuses +from .game import Minesweeper + + +@receiver(post_save, sender=Game) +def game_start(sender, signal, instance, **kwargs): + """ If the game was just created, insert the first event START_GAME """ + if not instance.status == GameStatuses.NOT_PLAYED: + return + GameEvent.objects.get_or_create(game=instance, type=EventTypes.START_GAME) + + +@receiver(pre_save, sender=GameEvent) +def identify_click_event(sender, signal, instance, **kwargs): + """ Verify what is on the naive click: mine, point or empty """ + if not instance.type == EventTypes.CLICK_NAIVE: + return + + if instance.row is None and instance.col is None: + return + + ms = Minesweeper( + instance.game.rows, + instance.game.cols, + instance.game.mines, + instance.game.board, + ) + + if ms.is_mine(instance.row, instance.col): + instance.type = EventTypes.CLICK_MINE + + elif ms.is_empty(instance.row, instance.col): + instance.type = EventTypes.CLICK_EMPTY + + elif ms.is_point(instance.row, instance.col): + instance.type = EventTypes.CLICK_POINT + + +@receiver(post_save, sender=GameEvent) +def create_post_save_game_event(sender, signal, instance, **kwargs): + ms = Minesweeper( + instance.game.rows, + instance.game.cols, + instance.game.mines, + instance.game.board, + instance.game.board_progress, + ) + game_changed = False + + reveal_events = [ + EventTypes.CLICK_POINT, + EventTypes.CLICK_EMPTY, + EventTypes.CLICK_MINE, + ] + if instance.type in reveal_events: + ms.reveal(instance.row, instance.col) + instance.game.board_progress = ms.board_progress + game_changed = True + + playing_events = [ + EventTypes.START_GAME, + EventTypes.RESUME, + EventTypes.CLICK_POINT, + EventTypes.CLICK_EMPTY, + EventTypes.CLICK_FLAG, + ] + + if instance.type in playing_events: + instance.game.status = GameStatuses.PLAYING + game_changed = True + + elif instance.type == EventTypes.PAUSE: + instance.game.status = GameStatuses.PAUSED + game_changed = True + + elif instance.type == EventTypes.CLICK_MINE: + instance.game.status = GameStatuses.FINISHED + instance.game.win = False + game_changed = True + + GameEvent(game=instance.game, type=EventTypes.GAME_OVER).save() + + if ms.win() is True: + instance.game.status = GameStatuses.FINISHED + instance.game.win = True + game_changed = True + + if game_changed is True: + instance.game.save()