diff --git a/app/bloonsa_api/views.py b/app/bloonsa_api/views.py index a94dd45..ed07304 100644 --- a/app/bloonsa_api/views.py +++ b/app/bloonsa_api/views.py @@ -4,9 +4,9 @@ from django.views.generic import TemplateView from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from bloonsa_game.models import Level, LevelRating +from bloonsa_game.models import Level, LevelRating, LevelScore from users.models import Player -from users.util import tag_player +from users.util import bloonsa_util, actions class CSRFexemptTemplateView(TemplateView): @@ -17,18 +17,21 @@ class CSRFexemptTemplateView(TemplateView): class LoadLevel(CSRFexemptTemplateView): def post(self, request, *args, **kwargs): - levelId = request.POST.get("levelId") - if levelId is None or not levelId.isdigit(): + level_id = request.POST.get("level_id") + if level_id is None or not level_id.isdigit(): return HttpResponseBadRequest() - level = Level.objects.filter(levelId=int(levelId)).first() + level = Level.objects.filter(levelId=int(level_id)).first() if level is None: return HttpResponseBadRequest() if request.user.is_authenticated: - tag_player(request=request) + bloonsa_util.tag_player(request=request) player = Player.objects.get(user=request.user) player.bloonsa_levelsPlayed.add(level) + bloonsa_util.log(player=player, + action=actions.bloonsa_load_level_by_id, + note=level.levelId) flashVars = level.getFlashVars(seperator="&") return HttpResponse(flashVars) @@ -40,9 +43,12 @@ class RandomLevel(CSRFexemptTemplateView): flashVars = level.getFlashVars(seperator="&") if request.user.is_authenticated: - tag_player(request=request) + bloonsa_util.tag_player(request=request) player = Player.objects.get(user=request.user) player.bloonsa_levelsPlayed.add(level) + bloonsa_util.log(player=player, + action=actions.bloonsa_load_random_level, + note=level.levelId) return HttpResponse(flashVars) @@ -52,11 +58,31 @@ class RandomLevel(CSRFexemptTemplateView): class CompleteLevel(CSRFexemptTemplateView): def post(self, request, *args, **kwargs): if request.user.is_authenticated: - tag_player(request=request) - levelId = int(request.POST.get("levelId")) - level = Level.objects.get(levelId=levelId) + bloonsa_util.tag_player(request=request) + level_id = int(request.POST.get("level_id")) + darts_left = int(request.POST.get("darts_left")) + pops = int(request.POST.get("pops")) + + level = Level.objects.get(levelId=level_id) player = Player.objects.get(user=request.user) - player.bloonsa_levelsBeaten.add(level) + + prevScore = player.bloonsa_level_scores.first() + if prevScore is None \ + or pops > prevScore.pops \ + or pops == prevScore.pops and darts_left > prevScore.darts_left: + score = LevelScore.objects.create(level=level, + clear=True, + darts_left=darts_left, + pops=pops) + if prevScore: + player.bloonsa_level_scores.remove(prevScore) + + player.bloonsa_level_scores.add(score) + score.save() + player.save() + bloonsa_util.log(player=player, + action=actions.bloonsa_submit_score, + note=level.levelId) # Sending empty content means error in as3 return HttpResponse(content="GG", status=200) @@ -64,16 +90,15 @@ class CompleteLevel(CSRFexemptTemplateView): class RateLevel(CSRFexemptTemplateView): def post(self, request, *args, **kwargs): if request.user.is_authenticated: - tag_player(request=request) + bloonsa_util.tag_player(request=request) rating = int(request.POST.get("rating")) - levelId = int(request.POST.get("levelId")) - level = Level.objects.get(levelId=levelId) + level_id = int(request.POST.get("level_id")) + level = Level.objects.get(levelId=level_id) player = Player.objects.get(user=request.user) ratingObject = Player.objects.filter(bloonsa_levelRatings__level=level).first() if ratingObject: ratingObject.rating = rating ratingObject.save() - return HttpResponse(content="OK", status=200) else: rating = LevelRating.objects.create(level=level, @@ -81,6 +106,10 @@ class RateLevel(CSRFexemptTemplateView): player.bloonsa_levelRatings.add(rating) rating.save() player.save() - return HttpResponse(content="OK", status=200) + + bloonsa_util.log(player=player, + action=actions.bloonsa_rate_level, + note=level.levelId) + return HttpResponse(content="OK", status=200) return HttpResponse(status=400) diff --git a/app/bloonsa_game/admin.py b/app/bloonsa_game/admin.py index b6cc388..1cbc983 100644 --- a/app/bloonsa_game/admin.py +++ b/app/bloonsa_game/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from .models import Level, Author +from .models import Level, Author, LevelScore admin.site.register(Level) -admin.site.register(Author) \ No newline at end of file +admin.site.register(Author) +admin.site.register(LevelScore) \ No newline at end of file diff --git a/app/bloonsa_game/migrations/0012_levelscore.py b/app/bloonsa_game/migrations/0012_levelscore.py new file mode 100644 index 0000000..bbfa2f2 --- /dev/null +++ b/app/bloonsa_game/migrations/0012_levelscore.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-02-13 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloonsa_game', '0011_levelrating'), + ] + + operations = [ + migrations.CreateModel( + name='LevelScore', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clear', models.BooleanField(default=True)), + ('darts_left', models.PositiveSmallIntegerField(null=True)), + ('pops', models.PositiveSmallIntegerField(null=True)), + ], + ), + ] diff --git a/app/bloonsa_game/migrations/0013_levelscore_level.py b/app/bloonsa_game/migrations/0013_levelscore_level.py new file mode 100644 index 0000000..29b25f5 --- /dev/null +++ b/app/bloonsa_game/migrations/0013_levelscore_level.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-02-13 22:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloonsa_game', '0012_levelscore'), + ] + + operations = [ + migrations.AddField( + model_name='levelscore', + name='level', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bloonsa_game.level'), + ), + ] diff --git a/app/bloonsa_game/migrations/0014_alter_levelscore_level.py b/app/bloonsa_game/migrations/0014_alter_levelscore_level.py new file mode 100644 index 0000000..d0d9d0e --- /dev/null +++ b/app/bloonsa_game/migrations/0014_alter_levelscore_level.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.6 on 2025-02-13 22:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloonsa_game', '0013_levelscore_level'), + ] + + operations = [ + migrations.AlterField( + model_name='levelscore', + name='level', + field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='bloonsa_game.level'), + preserve_default=False, + ), + ] diff --git a/app/bloonsa_game/models.py b/app/bloonsa_game/models.py index a379e02..88bf9b3 100644 --- a/app/bloonsa_game/models.py +++ b/app/bloonsa_game/models.py @@ -42,4 +42,18 @@ class LevelRating(models.Model): rating = models.SmallIntegerField() def __str__(self): - return f"{self.player.first()} - [<{int(self.rating) * '★'}> - {self.level}]" \ No newline at end of file + return (f"{self.player.first().user.username}" + f" - [<{int(self.rating) * '★'}> - {self.level.title}]") + +# There should only be 1 score per player +# Highest popcount wins +class LevelScore(models.Model): + level = models.ForeignKey(Level, on_delete=models.CASCADE) + clear = models.BooleanField(default=True) # This is for if we ever submit scores for failed attempts + darts_left = models.PositiveSmallIntegerField(null=True) + pops = models.PositiveSmallIntegerField(null=True) + + def __str__(self): + clearState = "✅" if self.clear else "❌" + return (f"{self.player.first().user.username}'s {clearState} @ {self.level.title}" + f"[🎈{self.pops} | 🎯{self.darts_left}]") \ No newline at end of file diff --git a/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited.swf b/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited.swf index 6fdb344..0e7387b 100644 Binary files a/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited.swf and b/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited.swf differ diff --git a/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited_edit6.swf b/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited_edit6.swf new file mode 100644 index 0000000..6fdb344 Binary files /dev/null and b/app/bloonsa_game/static/bloonsa_game/misc/bloons_unlimited_edit6.swf differ diff --git a/app/bloonsa_game/views.py b/app/bloonsa_game/views.py index 1218c15..3ce69cf 100644 --- a/app/bloonsa_game/views.py +++ b/app/bloonsa_game/views.py @@ -5,39 +5,41 @@ from django.views.generic import TemplateView from django.views.static import serve from bloonsa_game.models import Level -from users.util import tag_player, init_player +from users.util import bloonsa_util, actions class IndexView(TemplateView): def get(self, request, *args, **kwargs): - tag_player(request=request) + bloonsa_util.tag_player(request=request) return render(request, "bloonsa_game/index.html", context={}) class TermsView(TemplateView): def get(self, request, *args, **kwargs): - tag_player(request=request) + bloonsa_util.tag_player(request=request) return render(request, "bloonsa_game/terms.html", context={}) class GameView(TemplateView): def get(self, request, *args, **kwargs): - tag_player(request=request) + bloonsa_util.tag_player(request=request) # This init is for accounts made with 'createsuperuser' or originating from bloonsb - player = init_player(request=request) + player = bloonsa_util.init_player(request=request) # TODO get player object here with init_player to use in html template03.3.005 if type(kwargs.get("pk")) is int: level = Level.objects.get(id=kwargs["pk"]) if level: + bloonsa_util.log(player=player, + action=actions.bloonsa_load_level_by_url, + note=level) return render(request, "bloonsa_game/level.html", context={ "player": player, "flashVars": level.getFlashVars(seperator="&"), "levelTitle": level.title, "levelAuthor": level.author, }) - return render(request, "bloonsa_game/game.html", context={"player": player}) class WIPView(TemplateView): diff --git a/app/users/admin.py b/app/users/admin.py index 6df8749..fa704dd 100644 --- a/app/users/admin.py +++ b/app/users/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Player, LevelRating +from .models import Player, LevelRating, Log class PlayerAdmin(admin.ModelAdmin): @@ -9,3 +9,4 @@ class PlayerAdmin(admin.ModelAdmin): admin.site.register(LevelRating) admin.site.register(Player, PlayerAdmin) +admin.site.register(Log) diff --git a/app/users/migrations/0014_remove_player_bloonsa_levelsbeaten_and_more.py b/app/users/migrations/0014_remove_player_bloonsa_levelsbeaten_and_more.py new file mode 100644 index 0000000..6aa5273 --- /dev/null +++ b/app/users/migrations/0014_remove_player_bloonsa_levelsbeaten_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-02-13 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bloonsa_game', '0012_levelscore'), + ('users', '0013_alter_player_user'), + ] + + operations = [ + migrations.RemoveField( + model_name='player', + name='bloonsa_levelsBeaten', + ), + migrations.AddField( + model_name='player', + name='bloonsa_level_scores', + field=models.ManyToManyField(blank=True, related_name='player', to='bloonsa_game.levelscore'), + ), + migrations.AlterField( + model_name='player', + name='bloonsa_levelsPlayed', + field=models.ManyToManyField(blank=True, related_name='player', to='bloonsa_game.level'), + ), + ] diff --git a/app/users/migrations/0015_alter_player_user_log.py b/app/users/migrations/0015_alter_player_user_log.py new file mode 100644 index 0000000..9457069 --- /dev/null +++ b/app/users/migrations/0015_alter_player_user_log.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.6 on 2025-02-14 02:16 + +import django.db.models.deletion +import django.utils.timezone +import users.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_remove_player_bloonsa_levelsbeaten_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='player', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='player', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Log', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.PositiveSmallIntegerField(verbose_name=users.models.Log.Actions)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('note', models.TextField(null=True)), + ('player', models.ManyToManyField(related_name='log', to='users.player')), + ], + ), + ] diff --git a/app/users/migrations/0016_remove_log_player_log_player.py b/app/users/migrations/0016_remove_log_player_log_player.py new file mode 100644 index 0000000..5ea97bd --- /dev/null +++ b/app/users/migrations/0016_remove_log_player_log_player.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-02-14 02:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_alter_player_user_log'), + ] + + operations = [ + migrations.RemoveField( + model_name='log', + name='player', + ), + migrations.AddField( + model_name='log', + name='player', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='log', to='users.player'), + ), + ] diff --git a/app/users/migrations/0017_alter_log_player.py b/app/users/migrations/0017_alter_log_player.py new file mode 100644 index 0000000..41fc265 --- /dev/null +++ b/app/users/migrations/0017_alter_log_player.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-02-14 02:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_remove_log_player_log_player'), + ] + + operations = [ + migrations.AlterField( + model_name='log', + name='player', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='log', to='users.player'), + ), + ] diff --git a/app/users/migrations/0018_alter_log_action.py b/app/users/migrations/0018_alter_log_action.py new file mode 100644 index 0000000..fb8c0ed --- /dev/null +++ b/app/users/migrations/0018_alter_log_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-14 02:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0017_alter_log_player'), + ] + + operations = [ + migrations.AlterField( + model_name='log', + name='action', + field=models.PositiveSmallIntegerField(choices=[(0, 'login'), (1, 'register'), (2, 'logout'), (100, 'bloonsa_load_level_by_id'), (101, 'bloonsa_load_level_by_url'), (102, 'bloonsa_load_random_level'), (103, 'bloonsa_submit_score'), (104, 'bloonsa_rate_level')]), + ), + ] diff --git a/app/users/migrations/0019_alter_log_action.py b/app/users/migrations/0019_alter_log_action.py new file mode 100644 index 0000000..cd37731 --- /dev/null +++ b/app/users/migrations/0019_alter_log_action.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-14 02:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0018_alter_log_action'), + ] + + operations = [ + migrations.AlterField( + model_name='log', + name='action', + field=models.IntegerField(choices=[(0, 'login'), (1, 'register'), (2, 'logout'), (100, 'bloonsa_load_level_by_id'), (101, 'bloonsa_load_level_by_url'), (102, 'bloonsa_load_random_level'), (103, 'bloonsa_submit_score'), (104, 'bloonsa_rate_level')]), + ), + ] diff --git a/app/users/models.py b/app/users/models.py index 56b0ca7..f35398f 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -2,14 +2,14 @@ from django.db import models from django.utils import timezone from django.contrib.auth.models import User -from bloonsa_game.models import Level, LevelRating +from bloonsa_game.models import Level, LevelRating, LevelScore class Player(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="player") + user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True, related_name="player") # Savedata - bloonsa_levelsPlayed = models.ManyToManyField(Level, blank=True, related_name="levelsPlayed") - bloonsa_levelsBeaten = models.ManyToManyField(Level, blank=True, related_name="levelsBeaten") + bloonsa_levelsPlayed = models.ManyToManyField(Level, blank=True, related_name="player") + bloonsa_level_scores = models.ManyToManyField(LevelScore, blank=True, related_name="player") bloonsa_levelRatings = models.ManyToManyField(LevelRating, blank=True, related_name="player") # Logging creationIP = models.GenericIPAddressField() @@ -22,13 +22,15 @@ class Player(models.Model): banned = models.BooleanField(default=False) # Account gets logged out upon logging in admin = models.BooleanField(default=False) # Ability to suspend/ban other players + + # If bloonsa_levels_played gets replaced then this needs to be updated @property def levels_played(self): return self.bloonsa_levelsPlayed.count() @property def levels_beaten(self): - return self.bloonsa_levelsBeaten.count() + return self.bloonsa_level_scores.filter(clear=True).count() @property def total_levels(self): @@ -41,10 +43,25 @@ class Player(models.Model): "banned": "❌" if self.banned else "", "admin": "👑" if self.admin else "", } - states = "".join(statesDict.values()) - if states: - states += " " - - return f"{states}{self.user} - {self.latestIP}" + states = "".join(statesDict.values()) + " " + return f"{states}{self.user} - {self.latestIP}".strip(" ") +class Log(models.Model): + class Actions(models.IntegerChoices): + login = 0, "Logged in" + register = 1, "Registered" + logout = 2, "Logged out" + bloonsa_load_level_by_id = 100, "Loaded a level via ingame ID box" + bloonsa_load_level_by_url = 101, "Loaded a level via URL" + bloonsa_load_random_level = 102, "Loaded a random level" + bloonsa_submit_score = 103, "Submitted a score on level_id" + bloonsa_rate_level = 104, "Rated a level" + + player = models.ForeignKey(Player, related_name="log", on_delete=models.CASCADE, null=True) + action = models.IntegerField(choices=Actions) + timestamp = models.DateTimeField(default=timezone.now) + note = models.TextField(null=True) + + def __str__(self): + return f"{self.player.user.username} - {self.get_action_display()} <{self.note or ''}>" \ No newline at end of file diff --git a/app/users/util.py b/app/users/util.py index 00781d6..03688d7 100644 --- a/app/users/util.py +++ b/app/users/util.py @@ -1,48 +1,66 @@ from django.contrib.auth import logout from django.utils import timezone -from users.models import Player +from users.models import Player, Log -tracking = False +actions = Log.Actions -def get_ip(request): - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip = x_forwarded_for.split(",")[-1].strip() - else: - ip = request.META.get("REMOTE_ADDR") - return ip +class BloonsaUtil: -# Create a Player object for a User -def init_player(request): - if not request.user.is_authenticated: - return - player = Player.objects.filter(user=request.user).first() - if player: + def __init__(self): + self.tracking = True + + @staticmethod + def get_ip(request): + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + ip = x_forwarded_for.split(",")[-1].strip() + else: + ip = request.META.get("REMOTE_ADDR") + return ip + + # Create a Player object for a User + def init_player(self, request): + if not request.user.is_authenticated: + return + player = Player.objects.filter(user=request.user).first() + if player: + return player + ip = self.get_ip(request=request) + player = Player(user=request.user, + creationIP=ip, + latestIP=ip) + player.save() return player - ip = get_ip(request=request) - player = Player(user=request.user, - creationIP=ip, - latestIP=ip) - player.save() - return player -# Update activity timestamp and IP -def tag_player(request): - if not tracking: - return - if not request.user.is_authenticated: - return - player = Player.objects.filter(user=request.user).first() - if not player: - init_player(request=request) - if player.banned: - # TODO message popup? - logout(request) - return + # Update activity timestamp and IP + def tag_player(self, request): + if not self.tracking: + return + if not request.user.is_authenticated: + return + player = Player.objects.filter(user=request.user).first() + if not player: + self.init_player(request=request) + if player.banned: + # TODO message popup? + logout(request) + return - player.latestActivity = timezone.now() - player.latestIP = get_ip(request=request) - player.save() + player.latestActivity = timezone.now() + player.latestIP = self.get_ip(request=request) + player.save() + def log(self, action, note, player=None, request=None): + if not self.tracking: + return + if not player: + if not request: + return + player = self.init_player(request=request) + item = Log(player=player, + action=action, + note=note) + item.save() +bloonsa_util = BloonsaUtil() \ No newline at end of file diff --git a/app/users/views.py b/app/users/views.py index 00932fd..5e7aa9c 100644 --- a/app/users/views.py +++ b/app/users/views.py @@ -5,7 +5,7 @@ from django.views.generic import TemplateView from users.forms import UserRegisterForm, UserLoginForm from users.models import Player -from users.util import init_player, tag_player +from users.util import bloonsa_util, actions class LoginView(TemplateView): @@ -19,8 +19,10 @@ class LoginView(TemplateView): if not form.is_valid(): return render(request=request, template_name="users/login.html", context={"form": form}) user = form.get_user() - init_player(request=request) + player = bloonsa_util.init_player(request=request) login(request=request, user=user) + bloonsa_util.log(player=player, + action=actions.login) return redirect("bloonsa_game:game") @@ -35,8 +37,10 @@ class RegisterView(TemplateView): if not form.is_valid(): return render(request=request, template_name="users/register.html", context={"form": form}) user = form.save() - init_player(request=request) + player = bloonsa_util.init_player(request=request) login(request=request, user=user) + bloonsa_util.log(player=player, + action=actions.login) return redirect("bloonsa_game:game") @@ -45,7 +49,9 @@ class LogoutView(TemplateView): def get(self, request, *args, **kwargs): if request.user.is_authenticated: - tag_player(request=request) + bloonsa_util.tag_player(request=request) + bloonsa_util.log(request=request, + action=actions.logout) logout(request) return redirect("bloonsa_game:game")