diff --git a/src/apps/epic/migrations/0002_alter_room_renewal_period.py b/src/apps/epic/migrations/0002_alter_room_renewal_period.py new file mode 100644 index 0000000..dd7567f --- /dev/null +++ b/src/apps/epic/migrations/0002_alter_room_renewal_period.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0 on 2026-03-13 20:32 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='room', + name='renewal_period', + field=models.DurationField(blank=True, default=datetime.timedelta(days=7), null=True), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 2cc90bd..70d8754 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -1,9 +1,13 @@ import uuid +from datetime import timedelta from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.conf import settings +from django.utils import timezone + +from apps.lyric.models import Token class Room(models.Model): @@ -32,7 +36,7 @@ class Room(models.Model): ) visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE) gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING) - renewal_period = models.DurationField(null=True, blank=True) + renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7)) created_at = models.DateTimeField(auto_now_add=True) board_state = models.JSONField(default=dict) seed_count = models.IntegerField(default=12) @@ -67,3 +71,16 @@ def create_gate_slots(sender, instance, created, **kwargs): if created: for i in range(1, 7): GateSlot.objects.create(room=instance, slot_number=i) + + +def debit_token(user, slot, token): + if token.token_type == Token.COIN: + token.current_room = slot.room + token.next_ready_at = timezone.now() + slot.room.renewal_period + token.save() + else: + token.delete() + slot.gamer = user + slot.status = GateSlot.FILLED + slot.filled_at = timezone.now() + slot.save() diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index d200af7..5dff2d0 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -1,7 +1,9 @@ +from datetime import timedelta from django.test import TestCase +from django.urls import reverse -from apps.lyric.models import User -from apps.epic.models import Room, GateSlot +from apps.lyric.models import Token, User +from apps.epic.models import Room, GateSlot, debit_token class RoomCreationTest(TestCase): @@ -9,3 +11,54 @@ class RoomCreationTest(TestCase): owner = User.objects.create(email="founder@example.com") room = Room.objects.create(name="Test Room", owner=owner) self.assertEqual(GateSlot.objects.filter(room=room).count(), 6) + + +class DebitTokenTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="founder@example.com") + self.room = Room.objects.create( + name="Test Room", + owner=self.owner, + renewal_period=timedelta(days=7) + ) + self.slot = self.room.gate_slots.get(slot_number=1) + + def test_debit_free_token_consumes_token_and_fills_slot(self): + free_token = Token.objects.get(user=self.owner, token_type=Token.FREE) + debit_token(self.owner, self.slot, free_token) + self.assertFalse(Token.objects.filter(pk=free_token.pk).exists()) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.FILLED) + self.assertEqual(self.slot.gamer, self.owner) + + def test_debit_coin_does_not_consume_token(self): + coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN) + debit_token(self.owner, self.slot, coin_token) + self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists()) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.FILLED) + self.assertEqual(self.slot.gamer, self.owner) + + +class CoinTokenInUseTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="founder@example.com") + self.room = Room.objects.create( + name="Dragon's Den", + owner=self.owner, + renewal_period=timedelta(days=7), + ) + self.slot = self.room.gate_slots.get(slot_number=1) + self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN) + debit_token(self.owner, self.slot, self.coin) + self.coin.refresh_from_db() + + def test_coin_tooltip_expiry_shows_next_ready_date(self): + expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d") + self.assertIn(expected_date, self.coin.tooltip_expiry()) + + def test_coin_tooltip_room_html_contains_anchor(self): + room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + html = self.coin.tooltip_room_html() + self.assertIn(f'href="{room_url}"', html) + self.assertIn(self.room.name, html) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index ea24dac..9199dc2 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -29,3 +29,24 @@ class RoomCreationViewTest(TestCase): reverse("epic:create_room"), data={"name": "Test Room"}, ) + + +class MyGamesContextTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@example.com") + self.client.force_login(self.user) + + def test_gameboard_context_includes_owned_rooms(self): + room = Room.objects.create(name="Durango", owner=self.user) + response = self.client.get("/gameboard/") + self.assertIn(room, response.context["my_games"]) + + def test_gameboard_context_includes_rooms_with_filled_slot(self): + other = User.objects.create(email="friend@example.com") + room = Room.objects.create(name="Their Room", owner=other) + slot = room.gate_slots.get(slot_number=2) + slot.gamer = self.user + slot.status = "FILLED" + slot.save() + response = self.client.get("/gameboard/") + self.assertIn(room, response.context["my_games"]) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index e05d5a3..7220da8 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -7,5 +7,6 @@ app_name = 'epic' urlpatterns = [ path('rooms/create_room', views.create_room, name='create_room'), path('room//gate/', views.gatekeeper, name='gatekeeper'), + path('room//gate//drop_token', views.drop_token, name='drop_token'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index df5a7ce..b126dae 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,7 +1,8 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render -from apps.epic.models import Room +from apps.epic.models import Room, debit_token +from apps.lyric.models import Token @login_required @@ -20,3 +21,17 @@ def gatekeeper(request, room_id): 'room': room, 'slots': slots, }) + +@login_required +def drop_token(request, room_id, slot_number): + if request.method == "POST": + room = Room.objects.get(id=room_id) + slot = room.gate_slots.get(slot_number=slot_number) + token = ( + request.user.tokens.filter(token_type=Token.COIN).first() + or request.user.tokens.filter(token_type=Token.FREE).first() + or request.user.tokens.filter(token_type=Token.TITHE).first() + ) + if token: + debit_token(request.user, slot, token) + return redirect("epic:gatekeeper", room_id=room_id) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 2a9cdd2..15b9a86 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -1,8 +1,10 @@ from django.contrib.auth.decorators import login_required +from django.db.models import Q from django.shortcuts import redirect, render from apps.applets.utils import applet_context from apps.applets.models import Applet, UserApplet +from apps.epic.models import Room from apps.lyric.models import Token @@ -23,6 +25,9 @@ def gameboard(request): "free_tokens": free_tokens, "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", + "my_games": Room.objects.filter( + Q(owner=request.user) | Q(gate_slots__gamer=request.user) + ).distinct(), } ) @@ -40,5 +45,8 @@ def toggle_game_applets(request): "applets": applet_context(request.user, "gameboard"), "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), + "my_games": Room.objects.filter( + Q(owner=request.user) | Q(gate_slots__gamer=request.user) + ).distinct(), }) return redirect("gameboard") diff --git a/src/apps/lyric/migrations/0008_token_current_room_token_next_ready_at.py b/src/apps/lyric/migrations/0008_token_current_room_token_next_ready_at.py new file mode 100644 index 0000000..a3972a3 --- /dev/null +++ b/src/apps/lyric/migrations/0008_token_current_room_token_next_ready_at.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0 on 2026-03-13 20:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0001_initial'), + ('lyric', '0007_user_stripe_customer_id_paymentmethod'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='current_room', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coin_tokens', to='epic.room'), + ), + migrations.AddField( + model_name='token', + name='next_ready_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index fe9c6a6..99eb8d8 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -5,6 +5,7 @@ from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +from django.urls import reverse from django.utils import timezone @@ -79,6 +80,11 @@ class Token(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES) expires_at = models.DateTimeField(null=True, blank=True) + current_room = models.ForeignKey( + "epic.Room", null=True, blank=True, + on_delete=models.SET_NULL, related_name="coin_tokens" + ) + next_ready_at = models.DateTimeField(null=True, blank=True) def tooltip_name(self): return self.get_token_type_display() @@ -92,10 +98,18 @@ class Token(models.Model): def tooltip_expiry(self): if self.token_type == self.COIN: + if self.next_ready_at: + return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return "no expiry" if self.expires_at: return f"Expires {self.expires_at.strftime('%Y-%m-%d')}" return "" + + def tooltip_room_html(self): + if not self.current_room_id: + return "" + url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id}) + return f'{self.current_room.name}' def tooltip_shoptalk(self): if self.token_type == self.COIN: diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 46178ea..6143ae8 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -10,6 +10,9 @@ class GatekeeperTest(FunctionalTest): Applet.objects.get_or_create( slug="new-game", defaults={"name": "New Game", "context": "gameboard"} ) + Applet.objects.get_or_create( + slug="my-games", defaults={"name": "My Games", "context": "gameboard"} + ) def test_founder_creates_room_and_sees_gatekeeper(self): # 1. Log in, navigate to gameboard @@ -40,3 +43,50 @@ class GatekeeperTest(FunctionalTest): slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn") for slot in slots[1:]: self.assertIn("empty", slot.get_attribute("class")) + + def test_founder_drops_token_and_slot_fills(self): + # 1. Set up: log in, create room, arrive at gatekeeper + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_new_game_name") + ) + self.browser.find_element(By.ID, "id_new_game_name").send_keys("Dragon's Den") + self.browser.find_element(By.ID, "id_create_game_btn").click() + self.wait_for( + lambda: self.assertIn("/gate/", self.browser.current_url) + ) + # 2. Founder clicks Drop Token on slot 1 + drop_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn") + ) + drop_btn.click() + + # 3. Slot 1 now filled; drop btn gone + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") + self.assertIn("filled", slots[0].get_attribute("class")) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0 + ) + + def test_room_appears_in_my_games_after_creation(self): + # 1. Set up founder, game room, name + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_new_game_name") + ) + self.browser.find_element(By.ID, "id_new_game_name").send_keys("Dragon's Den") + self.browser.find_element(By.ID, "id_create_game_btn").click() + self.wait_for( + lambda: self.assertIn("/gate/", self.browser.current_url) + ) + # 2. Navigate back to gameboard + self.browser.get(self.live_server_url + "/gameboard/") + my_games = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_my_games") + ) + self.assertIn("Dragon's Den", my_games.text) diff --git a/src/templates/apps/gameboard/_partials/_applet-my-games.html b/src/templates/apps/gameboard/_partials/_applet-my-games.html index 67c1aac..3840616 100644 --- a/src/templates/apps/gameboard/_partials/_applet-my-games.html +++ b/src/templates/apps/gameboard/_partials/_applet-my-games.html @@ -4,6 +4,10 @@ >

My Games

    - [feature forthcoming] + {% for room in my_games %} +
  • {{ room.name }}
  • + {% empty %} +
  • No games yet
  • + {% endfor %}
\ No newline at end of file diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 2461e96..67726a0 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -7,7 +7,7 @@
{% for slot in slots %}
{{ slot.slot_number }} @@ -17,9 +17,12 @@ {% else %} empty {% endif %} - {% if slot.slot_number == 1 and request.user == room.owner %} - - {% endif %} + {% if slot.slot_number == 1 and request.user == room.owner and slot.status == 'EMPTY' %} +
+ {% csrf_token %} + +
+ {% endif %}
{% endfor %}