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

This commit is contained in:
Disco DeDisco
2026-03-19 15:48:59 -04:00
parent 5a811d0079
commit 91e0eaad8e
24 changed files with 593 additions and 0 deletions

View File

19
src/apps/drama/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DramaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.drama'

View 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'],
},
),
]

View File

88
src/apps/drama/models.py Normal file
View 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)

View File

View 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)

View File

View 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))