new DRAMA & BILLBOARD apps to start provenance system; new billboard.html & _scroll.html templates; admin area now displays game event log; new CLAUDE.md file to free up Claude Code's memory.md space; minor additions to apps.epic.views to ensure new systems just described adhere to existing game views
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# EarthmanRPG — Project Context
|
||||
|
||||
Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book.
|
||||
|
||||
## Browser Integration
|
||||
**Claudezilla** is installed — a Firefox extension + native host that lets Claude observe and interact with the browser directly. Use `mcp__claudezilla__*` tools to inspect the dev/staging server in Firefox (screenshots, console logs, DOM queries, navigation). Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`.
|
||||
|
||||
## Stack
|
||||
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
|
||||
- **Celery + Redis** (async email, channel layer)
|
||||
- **django-compressor + SCSS** (`src/static_src/scss/core.scss`)
|
||||
- **Selenium** (functional tests) + Django test framework (integration/unit tests)
|
||||
- **Stripe** (payment, sandbox only so far)
|
||||
- **Hosting:** DigitalOcean staging (`staging.earthmanrpg.me`) | CI: Gitea + Woodpecker
|
||||
|
||||
## Project Layout
|
||||
|
||||
The app pairs follow a tripartite structure:
|
||||
- **1st-person** (personal UX): `lyric` (backend — auth, user, tokens) · `dashboard` (frontend — notes, applets, wallet UI)
|
||||
- **3rd-person** (game table UX): `epic` (backend — rooms, gates, role select, game logic) · `gameboard` (frontend — room listing, gameboard UI)
|
||||
- **2nd-person** (inter-player events): `drama` (backend — activity streams, provenance) · `billboard` (frontend — provenance feed, embeddable in dashboard/gameboard)
|
||||
|
||||
```
|
||||
src/
|
||||
apps/
|
||||
lyric/ # auth (magic-link email), user model, token economy
|
||||
dashboard/ # Notes (formerly Lists), applets, wallet UI [1st-person frontend]
|
||||
epic/ # rooms, gates, role select, game logic [3rd-person backend]
|
||||
gameboard/ # room listing, gameboard UI [3rd-person frontend]
|
||||
drama/ # activity streams, provenance system [2nd-person backend]
|
||||
billboard/ # provenance feed, embeds in dashboard/gameboard [2nd-person frontend]
|
||||
api/ # REST API
|
||||
applets/ # Applet model + context helpers
|
||||
core/ # settings, urls, asgi, runner
|
||||
static_src/ # SCSS source
|
||||
templates/
|
||||
functional_tests/
|
||||
```
|
||||
|
||||
## Dev Commands
|
||||
```bash
|
||||
# Dev server (ASGI — required for WebSockets; no npm/webpack build step)
|
||||
cd src
|
||||
uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src
|
||||
|
||||
# Integration + unit tests only (from project root — targets src/apps, skips src/functional_tests)
|
||||
python src/manage.py test src/apps
|
||||
|
||||
# Functional tests only
|
||||
python src/manage.py test src/functional_tests
|
||||
|
||||
# All tests (integration + unit + FT)
|
||||
python src/manage.py test src
|
||||
```
|
||||
|
||||
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||
|
||||
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
|
||||
|
||||
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
|
||||
|
||||
## CI/CD
|
||||
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
|
||||
- Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me`
|
||||
- Push to `main` triggers Woodpecker → deploys to staging
|
||||
|
||||
## SCSS Import Order
|
||||
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → room → palette-picker → wallet-tokens`
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
### TransactionTestCase flushes migration data
|
||||
FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving.
|
||||
|
||||
### Static files in tests
|
||||
`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps/<app>/static/` or `src/static_src/`.
|
||||
|
||||
### msgpack integer key bug (Django Channels)
|
||||
`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys.
|
||||
|
||||
### Multi-browser FTs in CI
|
||||
Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`.
|
||||
|
||||
### Selenium + CSS text-transform
|
||||
Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`.
|
||||
|
||||
### Tooltip portal pattern
|
||||
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
|
||||
|
||||
### Applet menus + container-type
|
||||
`container-type: inline-size` creates a containing block for all positioned descendants — applet menus must live OUTSIDE `#id_applets_container` to be viewport-fixed.
|
||||
|
||||
### ABU session auth
|
||||
`create_pre_authenticated_session` must set `HASH_SESSION_KEY = user.get_session_auth_hash()` and hardcode `BACKEND_SESSION_KEY` to the passwordless backend string.
|
||||
|
||||
### Magic login email mock paths
|
||||
- View tests: `apps.lyric.views.send_login_email_task.delay`
|
||||
- Task unit tests: `apps.lyric.tasks.requests.post`
|
||||
- FTs: mock both with `side_effect=send_login_email_task`
|
||||
|
||||
## Teaching Style
|
||||
User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly.
|
||||
0
src/apps/billboard/__init__.py
Normal file
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillboardConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.billboard'
|
||||
10
src/apps/billboard/urls.py
Normal file
10
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.billboard import views
|
||||
|
||||
app_name = "billboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
]
|
||||
31
src/apps/billboard/views.py
Normal file
31
src/apps/billboard/views.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def billboard(request):
|
||||
my_rooms = Room.objects.filter(
|
||||
Q(owner=request.user) |
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||
).distinct().order_by("-created_at")
|
||||
return render(request, "apps/billboard/billboard.html", {
|
||||
"my_rooms": my_rooms,
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def room_scroll(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
events = room.events.select_related("actor").all()
|
||||
return render(request, "apps/billboard/room_scroll.html", {
|
||||
"room": room,
|
||||
"events": events,
|
||||
"viewer": request.user,
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
0
src/apps/drama/__init__.py
Normal file
0
src/apps/drama/__init__.py
Normal file
19
src/apps/drama/admin.py
Normal file
19
src/apps/drama/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
|
||||
|
||||
@admin.register(GameEvent)
|
||||
class GameEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("timestamp", "room", "actor", "verb")
|
||||
list_filter = ("verb",)
|
||||
readonly_fields = ("room", "actor", "verb", "data", "timestamp")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
6
src/apps/drama/apps.py
Normal file
6
src/apps/drama/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DramaConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.drama'
|
||||
32
src/apps/drama/migrations/0001_initial.py
Normal file
32
src/apps/drama/migrations/0001_initial.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0 on 2026-03-19 18:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('epic', '0006_table_status_and_table_seat'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GameEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('verb', models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed')], max_length=30)),
|
||||
('data', models.JSONField(default=dict)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='game_events', to=settings.AUTH_USER_MODEL)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='epic.room')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/apps/drama/migrations/__init__.py
Normal file
0
src/apps/drama/migrations/__init__.py
Normal file
88
src/apps/drama/models.py
Normal file
88
src/apps/drama/models.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class GameEvent(models.Model):
|
||||
# Gate phase
|
||||
ROOM_CREATED = "room_created"
|
||||
SLOT_RESERVED = "slot_reserved"
|
||||
SLOT_FILLED = "slot_filled"
|
||||
SLOT_RETURNED = "slot_returned"
|
||||
SLOT_RELEASED = "slot_released"
|
||||
INVITE_SENT = "invite_sent"
|
||||
# Role Select phase
|
||||
ROLE_SELECT_STARTED = "role_select_started"
|
||||
ROLE_SELECTED = "role_selected"
|
||||
ROLES_REVEALED = "roles_revealed"
|
||||
|
||||
VERB_CHOICES = [
|
||||
(ROOM_CREATED, "Room created"),
|
||||
(SLOT_RESERVED, "Gate slot reserved"),
|
||||
(SLOT_FILLED, "Gate slot filled"),
|
||||
(SLOT_RETURNED, "Gate slot returned"),
|
||||
(SLOT_RELEASED, "Gate slot released"),
|
||||
(INVITE_SENT, "Invite sent"),
|
||||
(ROLE_SELECT_STARTED, "Role select started"),
|
||||
(ROLE_SELECTED, "Role selected"),
|
||||
(ROLES_REVEALED, "Roles revealed"),
|
||||
]
|
||||
|
||||
room = models.ForeignKey(
|
||||
"epic.Room", on_delete=models.CASCADE, related_name="events",
|
||||
)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name="game_events",
|
||||
)
|
||||
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
|
||||
data = models.JSONField(default=dict)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["timestamp"]
|
||||
|
||||
def to_prose(self):
|
||||
"""Return a human-readable action description (actor rendered separately in template)."""
|
||||
d = self.data
|
||||
if self.verb == self.SLOT_FILLED:
|
||||
_token_names = {
|
||||
"coin": "Coin-on-a-String", "Free": "Free Token",
|
||||
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
|
||||
}
|
||||
code = d.get("token_type", "token")
|
||||
token = d.get("token_display") or _token_names.get(code, code)
|
||||
days = d.get("renewal_days", 7)
|
||||
slot = d.get("slot_number", "?")
|
||||
return f"deposits a {token} for slot {slot} ({days} days)"
|
||||
if self.verb == self.SLOT_RESERVED:
|
||||
return "reserves a seat"
|
||||
if self.verb == self.SLOT_RETURNED:
|
||||
return "withdraws from the gate"
|
||||
if self.verb == self.SLOT_RELEASED:
|
||||
return f"releases slot {d.get('slot_number', '?')}"
|
||||
if self.verb == self.ROOM_CREATED:
|
||||
return "opens this room"
|
||||
if self.verb == self.INVITE_SENT:
|
||||
return "sends an invitation"
|
||||
if self.verb == self.ROLE_SELECT_STARTED:
|
||||
return "Role selection begins"
|
||||
if self.verb == self.ROLE_SELECTED:
|
||||
_role_names = {
|
||||
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||
}
|
||||
code = d.get("role", "?")
|
||||
role = d.get("role_display") or _role_names.get(code, code)
|
||||
return f"starts as {role}"
|
||||
if self.verb == self.ROLES_REVEALED:
|
||||
return "All roles assigned"
|
||||
return self.verb
|
||||
|
||||
def __str__(self):
|
||||
actor = self.actor.email if self.actor else "system"
|
||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||
|
||||
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.epic.models import GateSlot, Room, TableSeat
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gamer@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||
self.slot.gamer = self.user
|
||||
self.slot.status = GateSlot.RESERVED
|
||||
self.slot.reserved_at = timezone.now()
|
||||
self.slot.save()
|
||||
|
||||
def test_confirm_token_records_slot_filled_event(self):
|
||||
session = self.client.session
|
||||
session["kit_token_id"] = str(self.token.id)
|
||||
session.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||||
|
||||
def test_no_event_recorded_if_no_reserved_slot(self):
|
||||
self.slot.gamer = None
|
||||
self.slot.status = GateSlot.EMPTY
|
||||
self.slot.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||||
|
||||
|
||||
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="player@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(
|
||||
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||||
)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.user, slot_number=1
|
||||
)
|
||||
|
||||
def test_select_role_records_role_selected_event(self):
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["role"], "PC")
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
|
||||
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
|
||||
# Only one seat — assigning it triggers roles_revealed
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertTrue(
|
||||
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
|
||||
)
|
||||
|
||||
def test_no_event_if_role_already_taken(self):
|
||||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||
0
src/apps/drama/tests/unit/__init__.py
Normal file
0
src/apps/drama/tests/unit/__init__.py
Normal file
44
src/apps/drama/tests/unit/test_models.py
Normal file
44
src/apps/drama/tests/unit/test_models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GameEventModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_record_creates_game_event(self):
|
||||
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
|
||||
self.assertEqual(GameEvent.objects.count(), 1)
|
||||
self.assertEqual(event.room, self.room)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||
|
||||
def test_record_without_actor(self):
|
||||
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||
self.assertIsNone(event.actor)
|
||||
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||||
|
||||
def test_events_ordered_by_timestamp(self):
|
||||
record(self.room, GameEvent.ROOM_CREATED)
|
||||
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
|
||||
verbs = list(GameEvent.objects.values_list("verb", flat=True))
|
||||
self.assertEqual(verbs, [
|
||||
GameEvent.ROOM_CREATED,
|
||||
GameEvent.SLOT_RESERVED,
|
||||
GameEvent.SLOT_FILLED,
|
||||
])
|
||||
|
||||
def test_str_includes_actor_and_verb(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||
self.assertIn("actor@test.io", str(event))
|
||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
@@ -7,6 +7,7 @@ from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
||||
from apps.lyric.models import Token
|
||||
|
||||
@@ -261,6 +262,10 @@ def confirm_token(request, room_id):
|
||||
if int(slot_number) > carte.slots_claimed:
|
||||
carte.slots_claimed = int(slot_number)
|
||||
carte.save()
|
||||
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||
slot_number=int(slot_number), token_type=Token.CARTE,
|
||||
token_display=carte.get_token_type_display(),
|
||||
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||
_notify_gate_update(room_id)
|
||||
else:
|
||||
slot = room.gate_slots.filter(
|
||||
@@ -275,6 +280,10 @@ def confirm_token(request, room_id):
|
||||
token = select_token(request.user)
|
||||
if token:
|
||||
debit_token(request.user, slot, token)
|
||||
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||
slot_number=slot.slot_number, token_type=token.token_type,
|
||||
token_display=token.get_token_type_display(),
|
||||
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
@@ -378,11 +387,15 @@ def select_role(request, room_id):
|
||||
return HttpResponse(status=409)
|
||||
active_seat.role = role
|
||||
active_seat.save()
|
||||
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
|
||||
role=role, slot_number=active_seat.slot_number,
|
||||
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
|
||||
if room.table_seats.filter(role__isnull=True).exists():
|
||||
_notify_turn_changed(room_id)
|
||||
else:
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
record(room, GameEvent.ROLES_REVEALED)
|
||||
_notify_roles_revealed(room_id)
|
||||
return HttpResponse(status=200)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
@@ -60,6 +60,8 @@ INSTALLED_APPS = [
|
||||
# Gamer apps
|
||||
'apps.lyric',
|
||||
'apps.epic',
|
||||
'apps.drama',
|
||||
'apps.billboard',
|
||||
# Custom apps
|
||||
'apps.api',
|
||||
'apps.applets',
|
||||
|
||||
@@ -13,6 +13,7 @@ urlpatterns = [
|
||||
path('lyric/', include('apps.lyric.urls')),
|
||||
path('gameboard/', include('apps.gameboard.urls')),
|
||||
path('gameboard/', include('apps.epic.urls')),
|
||||
path('billboard/', include('apps.billboard.urls')),
|
||||
]
|
||||
|
||||
# Please remove the following urlpattern
|
||||
|
||||
113
src/functional_tests/test_billboard.py
Normal file
113
src/functional_tests/test_billboard.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room, GateSlot
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class BillboardScrollTest(FunctionalTest):
|
||||
"""
|
||||
FT: game actions in room.html are logged to the drama stream, and the
|
||||
founder can navigate from any page to /billboard/ via the footer
|
||||
fa-scroll icon, select a room, and read the provenance scroll.
|
||||
|
||||
Events are seeded via ORM — the IT suite covers the recording side;
|
||||
here we test the user-visible navigation and prose display.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@scroll.io")
|
||||
self.other = User.objects.create(email="other@scroll.io")
|
||||
self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder)
|
||||
# Simulate two gate fills — one by founder, one by the other gamer
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.other,
|
||||
slot_number=2, token_type="Free",
|
||||
token_display="Free Token", renewal_days=7,
|
||||
)
|
||||
# Simulate founder selecting a role
|
||||
record(
|
||||
self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
|
||||
role="PC", slot_number=1, role_display="Player",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 1 — footer icon navigates to billboard, rooms listed #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_footer_scroll_icon_leads_to_billboard_with_rooms(self):
|
||||
# Founder logs in and lands on the dashboard
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/")
|
||||
|
||||
# Footer contains a scroll icon link pointing to /billboard/
|
||||
scroll_link = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_footer_nav a[href='/billboard/']")
|
||||
)
|
||||
scroll_link.click()
|
||||
|
||||
# Billboard page lists the founder's room
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/billboard/", self.browser.current_url)
|
||||
)
|
||||
body = self.browser.find_element(By.TAG_NAME, "body")
|
||||
self.assertIn("Blissful Ignorance", body.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 2 — scroll page renders human-readable prose #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_shows_human_readable_event_log(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
|
||||
# Click the room link to reach the scroll
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
|
||||
).click()
|
||||
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/scroll/", self.browser.current_url)
|
||||
)
|
||||
|
||||
scroll = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
|
||||
# Gate fill events are rendered as prose
|
||||
self.assertIn("deposits a Coin-on-a-String for slot 1 (7 days)", scroll.text)
|
||||
self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text)
|
||||
|
||||
# Role selection event is rendered as prose
|
||||
self.assertIn("starts as Player", scroll.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 3 — current user's events are right-aligned; others' are left #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_aligns_own_events_right_and_others_left(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
|
||||
).click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
|
||||
mine_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.mine")
|
||||
theirs_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.theirs")
|
||||
|
||||
# Founder has 2 events (slot fill + role select); other gamer has 1
|
||||
self.assertEqual(len(mine_events), 2)
|
||||
self.assertEqual(len(theirs_events), 1)
|
||||
|
||||
# The other gamer's event mentions their display name
|
||||
self.assertIn("other", theirs_events[0].text)
|
||||
18
src/templates/apps/billboard/billboard.html
Normal file
18
src/templates/apps/billboard/billboard.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}Billboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content dashboard-page">
|
||||
<h2>My Games</h2>
|
||||
<ul class="game-list">
|
||||
{% for room in my_rooms %}
|
||||
<li>
|
||||
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li><small>No games yet.</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
src/templates/apps/billboard/room_scroll.html
Normal file
10
src/templates/apps/billboard/room_scroll.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}{{ room.name }} — Drama Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content dashboard-page">
|
||||
<h2>{{ room.name }}</h2>
|
||||
{% include "apps/drama/_scroll.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
src/templates/apps/drama/_scroll.html
Normal file
16
src/templates/apps/drama/_scroll.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% load lyric_extras %}
|
||||
<section id="id_drama_scroll" class="drama-scroll">
|
||||
{% for event in events %}
|
||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||
<span class="event-body">
|
||||
<strong>{{ event.actor|display_name }}</strong>
|
||||
{{ event.to_prose }}<br>
|
||||
<time class="event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||
{{ event.timestamp|date:"N j, g:i a" }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="event-empty"><small>No events yet.</small></p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
@@ -6,6 +6,9 @@
|
||||
<a href="/gameboard/" class="{% if '/gameboard/' in request.path %}active{% endif %}">
|
||||
<i class="fa-solid fa-chess-board"></i>
|
||||
</a>
|
||||
<a href="/billboard/" class="{% if '/billboard/' in request.path %}active{% endif %}">
|
||||
<i class="fa-solid fa-scroll"></i>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="footer-container">
|
||||
<small>©{% now "Y" %} Dis Co.</small>
|
||||
|
||||
Reference in New Issue
Block a user