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
This commit is contained in:
2020-11-07 00:29:51 -03:00
committed by GitHub
parent 733f3e5992
commit 26de57fbfc
15 changed files with 494 additions and 14 deletions

View File

@@ -2,8 +2,8 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from game.models import Game from game.models import Game, GameEvent, GameStatuses
from ..serializers import GameSerializer from ..serializers import GameSerializer, GameEventSerializer
class GameResource(APIView): class GameResource(APIView):
@@ -28,3 +28,40 @@ class GameSingleResource(APIView):
serializer = GameSerializer(game) serializer = GameSerializer(game)
return Response(serializer.data, status=status.HTTP_200_OK) 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)

View File

@@ -1,4 +1,4 @@
from .game import GameSerializer from .game import GameSerializer, GameEventSerializer
__all__ = ["GameSerializer"] __all__ = ["GameSerializer", "GameEventSerializer"]

View File

@@ -1,9 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
from game.models import Game from game.models import Game, GameEvent
class GameSerializer(serializers.ModelSerializer): class GameSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Game model = Game
exclude = ["board"]
class GameEventSerializer(serializers.ModelSerializer):
class Meta:
model = GameEvent
fields = "__all__" fields = "__all__"

View File

@@ -1,12 +1,13 @@
from django.urls import path from django.urls import path
from .resources.main import MainResource from .resources.main import MainResource
from .resources.game import GameResource, GameSingleResource from .resources.game import GameResource, GameSingleResource, GameEventResource
app_name = "api" app_name = "api"
urlpatterns = [ urlpatterns = [
path("games/<game_id>/events", GameEventResource.as_view(), name="games_events"),
path("games/<game_id>", GameSingleResource.as_view(), name="games_single"), path("games/<game_id>", GameSingleResource.as_view(), name="games_single"),
path("games", GameResource.as_view(), name="games"), path("games", GameResource.as_view(), name="games"),
path("", MainResource.as_view(), name="main"), path("", MainResource.as_view(), name="main"),

View File

@@ -0,0 +1 @@
default_app_config = "game.apps.GameConfig"

View File

@@ -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",)

View File

@@ -5,3 +5,6 @@ class GameConfig(AppConfig):
name = "game" name = "game"
verbose_name = "Game" verbose_name = "Game"
verbose_name_plural = "Games" verbose_name_plural = "Games"
def ready(self):
import game.signals # noqa

View File

@@ -4,14 +4,23 @@ import random
class Minesweeper: class Minesweeper:
board = [] 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.rows = rows
self.cols = cols self.cols = cols
self.mines = mines 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): def create_board(self):
""" Creating the board cells with 0 as default value """ """ 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 = [[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): def put_mine(self):
"""Put a single mine on the board. """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) self.increment_safe_point(mine_position_row - 1, mine_position_col - 1)
def is_mine(self, row, col): 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: try:
return self.board[row][col] == -1 return self.board[row][col] == -1
except IndexError: except IndexError:
return False 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): def is_point_in_board(self, row, col):
""" Checks whether the location is inside board """ """ Checks whether the location is inside board """
if row in range(0, self.rows) and col in range(0, self.cols): 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 # Increment the value of the position becaus is close to some mine
self.board[row][col] += 1 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

View File

@@ -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",
},
),
]

View File

@@ -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",
),
),
]

View File

@@ -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",
),
),
]

View File

@@ -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",
),
]

View File

@@ -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",
),
),
]

View File

@@ -6,18 +6,23 @@ from internal.utils import empty_list
from .game import Minesweeper from .game import Minesweeper
class GameStatuses(IntEnum): class EnumChoicesBase(IntEnum):
""" Enum was used as choices of Game.status because explicit is better than implicit """ """ Enum was used as choices of Game.status because explicit is better than implicit """
NOT_PLAYED = 0
PLAYING = 1
FINISHED = 2
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(key.value, key.name) for key in 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): class Game(models.Model):
created_at = models.DateTimeField("Creation date", auto_now_add=True) created_at = models.DateTimeField("Creation date", auto_now_add=True)
modified_at = models.DateTimeField("Last update", auto_now=True) modified_at = models.DateTimeField("Last update", auto_now=True)
@@ -33,8 +38,14 @@ class Game(models.Model):
) )
board = JSONField( 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 = models.BooleanField(
"Win?", "Win?",
default=None, default=None,
@@ -56,6 +67,7 @@ class Game(models.Model):
ms.create_board() ms.create_board()
ms.put_mines() ms.put_mines()
self.board = ms.board self.board = ms.board
self.board_progress = ms.board_progress
super(Game, self).save(*args, **kwargs) super(Game, self).save(*args, **kwargs)
@@ -63,3 +75,49 @@ class Game(models.Model):
verbose_name = "Game" verbose_name = "Game"
verbose_name_plural = "Games" verbose_name_plural = "Games"
db_table = "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"

92
game/signals.py Normal file
View File

@@ -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()