diff --git a/README.md b/README.md index 900bca3..56e1fc9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,12 @@ $ python3 manage.py loaddata media/dump_data/hero_model_fixture.json $ docker run -p 6379:6379 -d redis:5 ``` -### run +### dev run +```shell +$ python3 manage.py runserver 0.0.0.0:8000 +``` + +### prod run ```shell $ daphne -b 0.0.0.0 -p 8000 chess_backend.asgi:application ``` diff --git a/chess_backend/__init__.py b/chess_backend/__init__.py index e69de29..5568b6d 100644 --- a/chess_backend/__init__.py +++ b/chess_backend/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/chess_backend/celery.py b/chess_backend/celery.py new file mode 100644 index 0000000..20f73ee --- /dev/null +++ b/chess_backend/celery.py @@ -0,0 +1,21 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chess_backend.settings") + +app = Celery("chess_backend") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f"Request: {self.request!r}") diff --git a/chess_backend/settings.py b/chess_backend/settings.py index 035bb14..712aa2c 100644 --- a/chess_backend/settings.py +++ b/chess_backend/settings.py @@ -6,8 +6,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-%_8sy196w4hzo9^cp9(@r=i+amh47r4mxfhq_(ok&=c(@%bhmk" -TOKEN_EXP = 2678400 # 31 day DEBUG = True +if DEBUG: + TOKEN_EXP = 31536000 # 1 year + AUTH_EXP = 31536000 # 1 year +else: + TOKEN_EXP = 2678400 # 1 month + AUTH_EXP = 3600 # 1 hour ALLOWED_HOSTS = [] @@ -28,21 +33,18 @@ INSTALLED_APPS = [ "room", ] -if DEBUG: - INSTALLED_APPS.append("drf_yasg") - TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -122,3 +124,9 @@ LOGGING = { }, }, } + +# Celery Configuration Options +CELERY_TIMEZONE = "Europe/Moscow" +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 +CELERY_BROKER_URL = 'redis://localhost:6379/0' diff --git a/chess_backend/urls.py b/chess_backend/urls.py index 34b266d..e60cf14 100644 --- a/chess_backend/urls.py +++ b/chess_backend/urls.py @@ -1,48 +1,9 @@ from django.conf import settings from django.conf.urls.static import static -from django.template.defaulttags import url from django.urls import path, include, re_path -# openapi schema -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi - - -schema_view = get_schema_view( - openapi.Info( - title="Snippets API", - default_version="v1", - description="Test description", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@snippets.local"), - license=openapi.License(name="BSD License"), - ), - public=True, - permission_classes=(permissions.AllowAny,), -) - urlpatterns = ( [path("api/", include("game.urls"))] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ) - -if settings.DEBUG: - urlpatterns += [ - re_path( - r"^swagger(?P\.json|\.yaml)$", - schema_view.without_ui(cache_timeout=0), - name="schema-json", - ), - re_path( - r"^swagger/$", - schema_view.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - re_path( - r"^redoc/$", - schema_view.with_ui("redoc", cache_timeout=0), - name="schema-redoc", - ), - ] diff --git a/common/debug.py b/common/debug.py new file mode 100644 index 0000000..5eae3be --- /dev/null +++ b/common/debug.py @@ -0,0 +1,48 @@ +from common.generators import gen_ton +from game.models import Player, Deck, Hero +from room.services.room_create import sync_create_room + + +def _check_players_score(players): + for player in players: + for player2 in players: + if player != player2: + s_min = players[player] * 0.95 + s_max = players[player] * 1.05 + if s_min <= players[player2] <= s_max: + return False + return True + + +def generate_room(): + players = {} + + for _ in range(2): + player = Player.objects.create(ton_wallet=gen_ton()) + players[player] = player.get_last_deck().score() + + while _check_players_score(players): + player = Player.objects.create(ton_wallet=gen_ton()) + players[player] = player.get_last_deck().score() + + for player in players: + for player2 in players: + if player != player2: + s_min = players[player] * 0.95 + s_max = players[player] * 1.05 + if s_min <= players[player2] <= s_max: + p1 = player + p2 = player2 + + room_slug = sync_create_room( + p1.get_last_deck().id, + p1.id, + players[p1], + p2.get_last_deck().id, + p2.id, + players[p2], + ) + print(f"ws://127.0.0.1:8000/room/{room_slug}") + print(f"Authorization: {p1.get_access_token()}") + print(f"Authorization: {p2.get_access_token()}") + return None diff --git a/game/api/v1/serializers.py b/game/api/v1/serializers.py index 7b2ef01..1cdf3c5 100644 --- a/game/api/v1/serializers.py +++ b/game/api/v1/serializers.py @@ -22,9 +22,7 @@ class GetHeroSerializer(serializers.ModelSerializer): fields = ( "added", "type", - "idle_img", - "attack_img", - "die_img", + "model", "health", "attack", "speed", @@ -37,15 +35,21 @@ class ListHeroSerializer(serializers.ModelSerializer): fields = ( "uuid", "type", - "idle_img", - "attack_img", - "die_img", + "model", "health", "attack", "speed", ) +class ListHeroInDeckSerializer(serializers.ModelSerializer): + hero = ListHeroSerializer() + + class Meta: + model = HeroInDeck + fields = ("hero", "x", "y") + + class CreatePlayerSerializer(serializers.ModelSerializer): class Meta: model = Player @@ -106,7 +110,7 @@ class GetPlayerSerializer(serializers.ModelSerializer): class GetDeckSerializer(serializers.ModelSerializer): player = GetPlayerSerializer() - heroes = ListHeroSerializer(many=True) + heroes = ListHeroInDeckSerializer(many=True) class Meta: model = Deck diff --git a/game/apps.py b/game/apps.py index 8ad49cb..25a8f7d 100644 --- a/game/apps.py +++ b/game/apps.py @@ -2,5 +2,8 @@ from django.apps import AppConfig class GameConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'game' + default_auto_field = "django.db.models.BigAutoField" + name = "game" + + def ready(self): + import game.signals diff --git a/game/migrations/0001_initial.py b/game/migrations/0001_initial.py index af633c5..943fd71 100644 --- a/game/migrations/0001_initial.py +++ b/game/migrations/0001_initial.py @@ -1,8 +1,6 @@ # Generated by Django 4.0.5 on 2022-06-04 14:16 -import django.core.validators -from django.db import migrations, models -import uuid +from django.db import migrations class Migration(migrations.Migration): @@ -13,45 +11,4 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='Hero', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('added', models.DateTimeField(auto_now_add=True)), - ('type', models.CharField(choices=[('WIZARD', 'wizard'), ('ARCHER', 'archer'), ('WARRIOR', 'warrior')], max_length=7)), - ('idle_img', models.ImageField(upload_to='uploads/idle')), - ('attack_img', models.ImageField(upload_to='uploads/attack')), - ('die_img', models.ImageField(upload_to='uploads/die')), - ('health', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])), - ('speed', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])), - ], - options={ - 'verbose_name': 'hero', - 'verbose_name_plural': 'heroes', - 'ordering': ['-added'], - }, - ), - migrations.CreateModel( - name='Player', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ton_wallet', models.CharField(max_length=50, verbose_name='TON wallet')), - ('name', models.CharField(blank=True, max_length=100)), - ('added', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'verbose_name': 'player', - 'verbose_name_plural': 'players', - 'ordering': ['-added'], - }, - ), - migrations.AddIndex( - model_name='player', - index=models.Index(fields=['ton_wallet'], name='game_player_ton_wal_47dd93_idx'), - ), - migrations.AddIndex( - model_name='hero', - index=models.Index(fields=['uuid'], name='game_hero_uuid_ada5d9_idx'), - ), ] diff --git a/game/models.py b/game/models.py index 222caff..4b5175f 100644 --- a/game/models.py +++ b/game/models.py @@ -9,14 +9,15 @@ from django.core.validators import ( ) from django.db import models +from chess_backend import settings from common.generators import generate_charset from game.services.jwt import sign_jwt class HeroTypes(models.TextChoices): - wizard = "WIZARD", "wizard" archer = "ARCHER", "archer" warrior = "WARRIOR", "warrior" + wizard = "WIZARD", "wizard" king = "KING", "king" @@ -32,32 +33,6 @@ class Player(models.Model): name = models.CharField(max_length=100, blank=True) created = models.DateTimeField(auto_now_add=True) - def save( - self, force_insert=False, force_update=False, using=None, update_fields=None - ): - """saves user and creates deck for him with 16 heroes""" - super(Player, self).save() - PlayerAuthSession.objects.create(player=self) - deck = Deck.objects.create(player=self) - types = ( - ["KING"] - + ["ARCHER" for _ in range(4)] - + ["WARRIOR" for _ in range(6)] - + ["WIZARD" for _ in range(2)] - + [random.choice(HeroTypes.choices[:3])[0] for _ in range(3)] - ) - for t in types: - hero = Hero() - hero.player = self - hero.type = t - - hero.health = random.randint(0, 10) - hero.attack = random.randint(0, 10) - hero.speed = random.randint(0, 10) - - hero.save() - HeroInDeck.objects.create(deck=deck, hero=hero) - def get_last_deck(self): return Deck.objects.filter(player=self).last() @@ -65,10 +40,13 @@ class Player(models.Model): return PlayerAuthSession.objects.get(player=self).jit def get_refresh_token(self): - return sign_jwt({"jit": self.get_auth_session(), "type": "refresh"}) + return sign_jwt( + {"jit": self.get_auth_session(), "type": "refresh"}, + t_life=settings.TOKEN_EXP, + ) def get_access_token(self): - return sign_jwt({"id": self.id, "type": "access"}, t_life=3600) + return sign_jwt({"id": self.id, "type": "access"}, t_life=settings.AUTH_EXP) def __str__(self): return self.name @@ -117,16 +95,8 @@ class Hero(models.Model): def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): - self.idle_img_f = random.choice( - [x for x in HeroModelSet.objects.filter(hero_type=self.type)] - ) - self.attack_img_f = random.choice( - [x for x in HeroModelSet.objects.filter(hero_type=self.type)] - ) - self.die_img_f = random.choice( - [x for x in HeroModelSet.objects.filter(hero_type=self.type)] - ) - super(Hero, self).save() + self.model_f = random.choice(HeroModelSet.objects.filter(hero_type=self.type)) + super(Hero, self).save(force_insert, force_update, using, update_fields) class Meta: indexes = [models.Index(fields=["uuid"])] @@ -157,14 +127,15 @@ class Deck(models.Model): return f"{self.player.name}'s deck" def get_heroes(self): - return [x.hero for x in HeroInDeck.objects.filter(deck=self)] + return HeroInDeck.objects.filter(deck=self) def heroes(self): - # added for better DRF view return self.get_heroes() def score(self): - return sum([x.attack + x.health + x.speed for x in self.get_heroes()]) + return sum( + [x.hero.attack + x.hero.health + x.hero.speed for x in self.get_heroes()] + ) class Meta: db_table = "deck" @@ -185,11 +156,19 @@ class HeroInDeck(models.Model): related_name="hero_in_deck", related_query_name="decks", ) + x = models.IntegerField( + blank=False, validators=[MinValueValidator(1), MaxValueValidator(8)] + ) + y = models.IntegerField( + blank=False, validators=[MinValueValidator(1), MaxValueValidator(2)] + ) class Meta: db_table = "hero_in_deck" verbose_name = "Hero in deck" verbose_name_plural = "Heroes in decks" + ordering = ["y", "x"] + unique_together = ["hero", "x", "y"] class PlayerAuthSession(models.Model): diff --git a/game/services/deck_handler.py b/game/services/deck_handler.py new file mode 100644 index 0000000..a892119 --- /dev/null +++ b/game/services/deck_handler.py @@ -0,0 +1,49 @@ +import random + +from game.models import Deck, Player, HeroTypes, Hero, HeroInDeck + + +def create_first_deck(player: Player): + deck = Deck.objects.create(player=player) + positions = [] + + for x in range(1, 9): + for y in range(1, 3): + positions.append((x, y)) + positions.remove((4, 1)) + positions.remove((5, 1)) + random.shuffle(positions) + + types = ["KING", "WIZARD"] + ["ARCHER" for _ in range(4)] + ["WARRIOR" for _ in range(6)] + + for _ in range(4): + t = random.choice(HeroTypes.choices[:3])[0] + if t == "WIZARD" and types.count("WIZARD") > 1: + t = random.choice(HeroTypes.choices[:2])[0] + types.append(t) + + counter = 0 + for t in types: + hero = Hero() + hero.player = player + hero.type = t + + # set random position on deck for heroes + if t == "KING": + pos_x = 5 + pos_y = 1 + elif t == "WIZARD": + pos_x = 4 + pos_y = 1 + else: + pos_x = positions[counter][0] + pos_y = positions[counter][1] + + counter += 1 + + hero.health = random.randint(1, 10) + hero.attack = random.randint(1, 10) + hero.speed = random.randint(1, 10) + + hero.save() + HeroInDeck.objects.create(deck=deck, hero=hero, x=pos_x, y=pos_y) diff --git a/game/signals.py b/game/signals.py new file mode 100644 index 0000000..c3190d9 --- /dev/null +++ b/game/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import Player, PlayerAuthSession +from .services.deck_handler import create_first_deck + + +@receiver(post_save, sender=Player) +def create_player(sender, instance, created, **kwargs): + if created: + PlayerAuthSession.objects.create(player=instance) + create_first_deck(instance) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 767bdf9..0000000 --- a/requirements.txt +++ /dev/null @@ -1,46 +0,0 @@ -asgiref==3.5.2 -asttokens==2.0.5 -attrs==21.4.0 -autobahn==22.5.1 -Automat==20.2.0 -backcall==0.2.0 -cffi==1.15.0 -channels==3.0.4 -constantly==15.1.0 -cryptography==37.0.2 -daphne==3.0.2 -decorator==5.1.1 -Django==4.0.5 -djangorestframework==3.13.1 -executing==0.8.3 -hyperlink==21.0.0 -idna==3.3 -incremental==21.3.0 -ipython==8.4.0 -jedi==0.18.1 -matplotlib-inline==0.1.3 -parso==0.8.3 -pexpect==4.8.0 -pickleshare==0.7.5 -Pillow==9.1.1 -prompt-toolkit==3.0.29 -ptyprocess==0.7.0 -pure-eval==0.2.2 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pycparser==2.21 -Pygments==2.12.0 -PyJWT==2.4.0 -pyOpenSSL==22.0.0 -pytz==2022.1 -service-identity==21.1.0 -six==1.16.0 -sqlparse==0.4.2 -stack-data==0.2.0 -traitlets==5.2.2.post1 -Twisted==22.4.0 -txaio==22.2.1 -typing_extensions==4.2.0 -wcwidth==0.2.5 -zope.interface==5.4.0 -channels_redis diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..c274de8 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,8 @@ +Django==4.0.5 +channels==3.0.4 +djangorestframework==3.13.1 +Pillow==9.1.1 +PyJWT==2.4.0 +channels-redis==3.4.1 +celery==5.2.7 +redis==4.3.4 \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..66015c4 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,4 @@ +-r base.txt + +ipython==8.4.0 +termcolor==1.1.0 \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..fcfa020 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1,3 @@ +-r base.txt + +daphne==3.0.2 \ No newline at end of file diff --git a/room/consumers.py b/room/consumers.py index 7c5e772..8455b62 100644 --- a/room/consumers.py +++ b/room/consumers.py @@ -4,7 +4,8 @@ import django from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncWebsocketConsumer -from channels.layers import get_channel_layer + +from room.services.game_logic import move_handler os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chess_backend.settings") django.setup() @@ -13,10 +14,16 @@ from game.models import Deck from room.models import PlayerInQueue, Room, PlayerInRoom, GameState from room.services.room_create import create_room -channel_layer = get_channel_layer() + +class BaseConsumer(AsyncWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + + async def send_message(self, message_type: str, **data): + await self.send(text_data=json.dumps({"type": message_type, **data})) -class QueueConsumer(AsyncWebsocketConsumer): +class QueueConsumer(BaseConsumer): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self.room_group_name = None @@ -27,6 +34,14 @@ class QueueConsumer(AsyncWebsocketConsumer): await self.accept() await self.check_origin() + if await self.check_user_already_in_room(): + await self.send_message( + "INFO", + message=f"user already in room {self.scope['room_id']}", + room=self.scope["room"], + ) + await self.close() + await self.channel_layer.group_add(self.room_group_name, self.channel_name) async def disconnect(self, close_code): @@ -40,27 +55,17 @@ class QueueConsumer(AsyncWebsocketConsumer): try: data = json.loads(text_data) except ValueError: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "data is not JSON serializable"} - ) - ) + await self.send_message("ERROR", message="data is not JSON serializable") if data: # TODO move to external function/class if "type" not in data: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "incorrect data typing"} - ) - ) + await self.send_message("ERROR", message="incorrect data typing") else: if data["type"] == "connect": if "deck_id" not in data: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "deck id is not provided"} - ) + await self.send_message( + "ERROR", message="deck id is not provided" ) else: deck = None @@ -69,32 +74,22 @@ class QueueConsumer(AsyncWebsocketConsumer): deck_id = int(data["deck_id"]) deck = await self.check_user_deck(deck_id) except ValueError: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "deck id is incorrect"} - ) + await self.send_message( + "ERROR", message="deck id is incorrect" ) + if deck: # add to que, start finding players await self.queue_connector(deck) - await self.send( - text_data=json.dumps( - { - "type": "INFO", - "message": f"added to queue deck with score {self.scope['score']}", - } - ) + await self.send_message( + "INFO", + message=f"added to queue deck with score {self.scope['score']}", ) opponent = await self.find_user_by_score() if not opponent: - await self.send( - text_data=json.dumps( - { - "type": "INFO", - "message": "no user found, awaiting in queue", - } - ) + await self.send_message( + "INFO", message="no user found, awaiting in queue" ) else: # add to group and send message that opponent found to players @@ -116,32 +111,35 @@ class QueueConsumer(AsyncWebsocketConsumer): }, ) - await self.send( - text_data=json.dumps( - { - "type": "INFO", - "message": f"user found, with score {opponent[1]}", - "room": room, - } - ) + await self.send_message( + "INFO", + message=f"user found, with score {opponent[1]}", + room=room, ) else: - await self.send( - text_data=json.dumps( - { - "type": "ERROR", - "message": "such deck doesn't exist", - } - ) + await self.send_message( + "ERROR", message="such deck doesn't exist" ) @sync_to_async def delete_user_in_queue(self): try: - PlayerInQueue.objects.get(player_id=self.scope["player"]).delete() + PlayerInQueue.objects.get(player_id=self.scope["player"]) except PlayerInQueue.DoesNotExist: return False + @sync_to_async + def check_user_already_in_room(self): + try: + p = PlayerInRoom.objects.get(player_id=self.scope["player"]) + + self.scope["room"] = p.room.slug + self.scope["room_id"] = p.room.id + + return True + except PlayerInRoom.DoesNotExist: + return False + @sync_to_async def find_user_by_score(self): s_min = self.scope["score"] * 0.95 @@ -183,24 +181,20 @@ class QueueConsumer(AsyncWebsocketConsumer): self.scope["score"] = queue.score async def info(self, event): - message = event["message"] - msg = {"type": "INFO", "message": message} if "room" in event: - msg["room"] = event["room"] - - await self.send(text_data=json.dumps(msg)) + await self.send_message( + "INFO", message=event["message"], room=event["room"] + ) + else: + await self.send_message("INFO", message=event["message"]) async def check_origin(self): if not self.scope["player"]: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "token is incorrect or expired"} - ) - ) + await self.send_message("ERROR", message="token is incorrect or expired") await self.close() -class RoomConsumer(AsyncWebsocketConsumer): +class RoomConsumer(BaseConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.room_group_name = None @@ -215,18 +209,14 @@ class RoomConsumer(AsyncWebsocketConsumer): else: message, round = await self.get_state() - await self.send( - json.dumps( - { - "type": "INFO", - "opponent_score": self.scope["opponent_score"], - "opponent_deck": self.scope["opponent_deck"], - "opponent_online": self.scope["opponent_online"], - "first": self.scope["first"], - "state": message, - "round": round, - }, - ) + await self.send_message( + "INFO", + opponent_score=self.scope["opponent_score"], + opponent_deck=self.scope["opponent_deck"], + opponent_online=self.scope["opponent_online"], + first=self.scope["first"], + state=message, + round=round, ) if "opponent_channel" in self.scope and self.scope["opponent_channel"]: await self.channel_layer.send( @@ -244,6 +234,10 @@ class RoomConsumer(AsyncWebsocketConsumer): # Join room group await self.channel_layer.group_add(self.room_group_name, self.channel_name) + # load and send board + await self.load_board() + await self.send_board() + @sync_to_async def get_state(self): state = self.scope["player_in_room"].get_state() @@ -260,7 +254,7 @@ class RoomConsumer(AsyncWebsocketConsumer): if not room: return False - self.scope["room"] = room + self.scope["room"] = room.first() # check if player can be in a room p_ids = [x.player.id for x in room.first().players.all()] @@ -274,6 +268,7 @@ class RoomConsumer(AsyncWebsocketConsumer): self.scope["first"] = player.first self.scope["score"] = player.score self.scope["deck"] = player.deck.id + self.scope["state"] = room.first().states.last().round p_ids.remove(player.player.id) opponent = PlayerInRoom.objects.get(player_id=p_ids[0]) @@ -315,20 +310,19 @@ class RoomConsumer(AsyncWebsocketConsumer): try: data = json.loads(text_data) except ValueError: - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "data is not JSON serializable"} - ) - ) + await self.send_message("ERROR", message="data is not JSON serializable") if data: - if data["type"] == "start": + if "type" not in data: + await self.send_message("ERROR", message="incorrect data typing") + elif data["type"] == "start": if not await self.start(data): - await self.send( - text_data=json.dumps( - {"type": "ERROR", "message": "opponent is offline"} - ) - ) + await self.send_message("ERROR", message="opponent is offline") + elif data["type"] == "move": + if all(x in data for x in ["x", "y", "px", "py"]): + await self.perform_move(data) + else: + await self.send_message("ERROR", message="incorrect data typing") async def start(self, data): if self.scope["opponent_channel"] and self.scope["opponent_online"]: @@ -342,6 +336,57 @@ class RoomConsumer(AsyncWebsocketConsumer): return True return False + async def perform_move(self, data): + if await move_handler( + data["px"], + data["py"], + data["x"], + data["y"], + self.room_name, + self.scope["player_in_room"], + ): + await self.send_board() + if self.scope["opponent_channel"] and self.scope["opponent_online"]: + await self.channel_layer.send( + self.scope["opponent_channel"], + { + "type": "move", + "x": data["x"], + "y": data["y"], + "px": data["px"], + "py": data["py"], + }, + ) + return True + return False + + @sync_to_async + def load_board(self): + # loads bord from db to scope + room = self.scope["room"] + board = [ + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None], + ] + for el in room.heroes.all(): + board[el.y - 1][el.x - 1] = [el.hero.type, el.health] + + self.scope["board"] = board + + async def send_board(self): + # sends board to client + await self.send_message( + "INFO", + message=f"game's board for round {self.scope['state']}", + board=self.scope["board"], + ) + # info type group message handler async def info(self, event): message = event["message"] @@ -367,13 +412,6 @@ class RoomConsumer(AsyncWebsocketConsumer): await self.send(text_data=json.dumps(msg)) - # Receive message from room group - async def chat_message(self, event): - message = event["message"] - - # Send message to WebSocket - await self.send(text_data=json.dumps({"lot": message})) - async def channel(self, event): channel = event["channel"] self.scope["opponent_channel"] = channel @@ -392,6 +430,19 @@ class RoomConsumer(AsyncWebsocketConsumer): ) self.scope["opponent_online"] = status + async def move(self, event): + await self.send( + text_data=json.dumps( + { + "type": "MOVE", + "x": event["x"], + "y": event["y"], + "px": event["px"], + "py": event["py"], + } + ) + ) + async def check_origin(self): if not self.scope["player"]: await self.send( diff --git a/room/models.py b/room/models.py index 047412b..5e64bec 100644 --- a/room/models.py +++ b/room/models.py @@ -1,7 +1,8 @@ +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models # Create your models here. -from game.models import Player, Deck +from game.models import Player, Deck, Hero class PlayerInQueue(models.Model): @@ -41,10 +42,40 @@ class PlayerInRoom(models.Model): class GameState(models.Model): - room = models.ForeignKey(Room, on_delete=models.CASCADE) + room = models.ForeignKey(Room, related_name="states", on_delete=models.CASCADE) player = models.ForeignKey(Player, on_delete=models.CASCADE) round = models.IntegerField(blank=False) message = models.CharField(max_length=100, blank=False) class Meta: unique_together = ["room", "player", "round"] + + +class HeroInGame(models.Model): + hero = models.ForeignKey(Hero, on_delete=models.CASCADE) + player = models.ForeignKey(PlayerInRoom, on_delete=models.CASCADE) + room = models.ForeignKey(Room, related_name="heroes", on_delete=models.CASCADE) + + # state on board + x = models.IntegerField( + blank=False, validators=[MinValueValidator(1), MaxValueValidator(8)] + ) + y = models.IntegerField( + blank=False, validators=[MinValueValidator(1), MaxValueValidator(8)] + ) + health = models.IntegerField(blank=False) + dead = models.BooleanField(default=False) + + def __str__(self): + return f"{self.hero.type} in room {self.room.slug}" + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if not self.health and not self.dead: + self.health = self.hero.health + + super().save(force_insert, force_update, using, update_fields) + + class Meta: + unique_together = ["x", "y", "hero"] diff --git a/room/services/game_logic.py b/room/services/game_logic.py new file mode 100644 index 0000000..ad8a776 --- /dev/null +++ b/room/services/game_logic.py @@ -0,0 +1,110 @@ +from asgiref.sync import sync_to_async +from termcolor import colored + +from room.models import HeroInGame, Room, PlayerInRoom + + +def _check_path(f_x: int, f_y: int, x: int, y: int, room: Room, move_type: str): + if move_type == "DIAGONAL": + return ( + HeroInGame.objects.filter( + room=room, x__range=(f_x, x), y__range=(f_y, y) + ).count() + == 0 + ) + elif move_type == "HORIZONTAL": + return HeroInGame.objects.filter(room=room, x=x, y__range=(f_y, y)).count() == 0 + elif move_type == "VERTICAL": + return HeroInGame.objects.filter(room=room, x__range=(f_x, x), y=y).count() == 0 + return False + + +def _validate_hero_movement( + hero_type: str, + prev_x: int, + prev_y: int, + x: int, + y: int, + room: Room, + first: bool = False, # needed for warrior +): + if hero_type == "KING": + if abs(x - prev_x) > 1 or abs(y - prev_y) > 1: + return False + elif hero_type == "WIZARD": + if abs(x - prev_x) == abs(y - prev_y): + return _check_path(prev_x, prev_y, x, y, room, "DIAGONAL") + elif x == prev_x and y != prev_y: + return _check_path(prev_x, prev_y, x, y, room, "HORIZONTAL") + elif x != prev_x and y == prev_y: + return _check_path(prev_x, prev_y, x, y, room, "VERTICAL") + return False + elif hero_type == "ARCHER": + if abs(x - prev_x) == abs(y - prev_y): + return _check_path(prev_x, prev_y, x, y, room, "DIAGONAL") + return False + elif hero_type == "WARRIOR": + if first: + if x == prev_x and y - prev_y == 1: + return True + elif abs(x - prev_x) == 1 and y - prev_y == 1: + return True + else: + if x == prev_x and prev_y - y == 1: + return True + return False + + +def _print_board(room: Room): + for y in range(1, 9): + for x in range(1, 9): + try: + hero = HeroInGame.objects.get(x=x, y=y, room=room) + if hero.hero.type == "KING": + if hero.player.first: + print(colored("♔", 'green', attrs=['bold']), end="") + else: + print(colored("♚", 'red', attrs=['bold']), end="") + elif hero.hero.type == "WIZARD": + if hero.player.first: + print(colored("♕", 'green', attrs=['bold']), end="") + else: + print(colored("♛", 'red', attrs=['bold']), end="") + elif hero.hero.type == "ARCHER": + if hero.player.first: + print(colored("♗", 'green', attrs=['bold']), end="") + else: + print(colored("♝", 'red', attrs=['bold']), end="") + else: + if hero.player.first: + print(colored("♙", 'green', attrs=['bold']), end="") + else: + print(colored("♟", 'red', attrs=['bold']), end="") + except HeroInGame.DoesNotExist: + print("*", end="") + print() + + +@sync_to_async +def move_handler( + prev_x: int, prev_y: int, x: int, y: int, room_slug: str, player: PlayerInRoom +): + room = Room.objects.get(slug=room_slug) + _print_board(room) # TODO: Remove in production + try: + hero = HeroInGame.objects.get(x=prev_x, y=prev_y, room=room, player=player) + except HeroInGame.DoesNotExist: + return False + + if x == prev_x and y == prev_y: + return False + + h_t = hero.hero.type + + if _validate_hero_movement(h_t, prev_x, prev_y, x, y, room, first=player.first): + hero.x = x + hero.y = y + hero.save(update_fields=["x", "y"]) + return True + + _print_board(room) # TODO: Remove in production diff --git a/room/services/read_message.py b/room/services/read_message.py new file mode 100644 index 0000000..e69de29 diff --git a/room/services/room_create.py b/room/services/room_create.py index 46e4bfa..1ac38a7 100644 --- a/room/services/room_create.py +++ b/room/services/room_create.py @@ -2,26 +2,26 @@ from asgiref.sync import sync_to_async from random import randint from common.generators import generate_charset -from game.models import Player -from room.models import Room, PlayerInRoom, GameState +from game.models import Player, Deck +from room.models import Room, PlayerInRoom, GameState, HeroInGame -@sync_to_async -def create_room( +def sync_create_room( deck_id_1: int, player_id_1: int, player_score_1: int, deck_id_2: int, player_id_2: int, player_score_2: int, -) -> str: +): + room = Room.objects.create(slug=generate_charset(16)) player_1 = Player.objects.get(id=player_id_1) player_2 = Player.objects.get(id=player_id_2) first_player = randint(1, 2) - PlayerInRoom.objects.create( + p1 = PlayerInRoom.objects.create( player=player_1, room=room, score=player_score_1, @@ -29,17 +29,37 @@ def create_room( first=first_player == 1, ) - PlayerInRoom.objects.create( + p2 = PlayerInRoom.objects.create( player=player_2, room=room, score=player_score_2, deck_id=deck_id_2, first=first_player == 2, ) - GameState.objects.create( - room=room, player=player_1, round=0, message="Game started" - ) - GameState.objects.create( - room=room, player=player_2, round=0, message="Game started" - ) + for p, d_id in [(p1, deck_id_1), (p2, deck_id_2)]: + GameState.objects.create( + room=room, player=p.player, round=0, message="Game started" + ) + for hero_in_deck in Deck.objects.get(id=d_id).heroes(): + if p.first: + HeroInGame.objects.create( + hero=hero_in_deck.hero, + player=p, + room=room, + x=hero_in_deck.x, + y=hero_in_deck.y, + ) + else: + HeroInGame.objects.create( + hero=hero_in_deck.hero, + player=p, + room=room, + x=hero_in_deck.x, + y=8 if hero_in_deck.y == 1 else 7, + ) return room.slug + + +@sync_to_async +def create_room(**kwargs): + return sync_create_room(**kwargs) diff --git a/room/tasks.py b/room/tasks.py new file mode 100644 index 0000000..c41f66a --- /dev/null +++ b/room/tasks.py @@ -0,0 +1,3 @@ +from celery import shared_task + +# TODO: add timeout for state