Files
python-tdd/src/apps/epic/views.py

466 lines
18 KiB
Python
Raw Normal View History

from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required
from django.db import transaction
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
RESERVE_TIMEOUT = timedelta(seconds=60)
def _notify_gate_update(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'gate_update'},
)
def _notify_turn_changed(room_id):
active_seat = TableSeat.objects.filter(
room_id=room_id, role__isnull=True
).order_by("slot_number").first()
active_slot = active_seat.slot_number if active_seat else None
starter_roles = list(
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
.values_list("role", flat=True)
)
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
)
def _notify_roles_revealed(room_id):
assignments = {
str(seat.slot_number): seat.role
for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number")
}
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'roles_revealed', 'assignments': assignments},
)
def _notify_role_select_start(room_id):
slot_order = list(
GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED)
.order_by("slot_number")
.values_list("slot_number", flat=True)
)
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'role_select_start', 'slot_order': slot_order},
)
def _expire_reserved_slots(room):
cutoff = timezone.now() - RESERVE_TIMEOUT
room.gate_slots.filter(
status=GateSlot.RESERVED,
reserved_at__lt=cutoff,
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
def _gate_context(room, user):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
user_reserved_slot = None
user_filled_slot = None
carte_token = None
carte_slots_claimed = 0
carte_nvm_slot_number = None
carte_next_slot_number = None
if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
carte_token = user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte_token:
carte_slots_claimed = carte_token.slots_claimed
# NVM shown on the highest-numbered slot this user filled via CARTE
nvm_slot = slots.filter(
debited_token_type=Token.CARTE, gamer=user, status=GateSlot.FILLED
).order_by("-slot_number").first()
if nvm_slot:
carte_nvm_slot_number = nvm_slot.slot_number
# Only the very next empty slot gets an OK button
next_slot = slots.filter(status=GateSlot.EMPTY).order_by("slot_number").first()
if next_slot:
carte_next_slot_number = next_slot.slot_number
carte_active = carte_token is not None
eligible = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
and not carte_active
)
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
is_last_slot = (
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
)
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
return {
"slots": slots,
"pending_slot": pending_slot,
"user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot,
"can_drop": can_drop,
"token_depleted": token_depleted,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
"carte_active": carte_active,
"carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number,
}
def _role_select_context(room, user):
user_seat = None
active_seat = None
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
if unassigned.exists():
# Normal path — TableSeats present
active_seat = unassigned.first()
user_seat = None
if user.is_authenticated:
user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first()
if user_seat and user_seat.slot_number == active_seat.slot_number:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
else:
# Fallback — no TableSeats yet; use GateSlot drop order
active_slot = room.gate_slots.filter(
status=GateSlot.FILLED
).order_by("slot_number").first()
if active_slot is None:
card_stack_state = None
elif user.is_authenticated and active_slot.gamer == user:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
starter_roles = list(
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
)
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
assigned_seats = (
sorted(
room.table_seats.filter(gamer=user, role__isnull=False),
key=lambda s: _action_order.get(s.role, 99),
)
if user.is_authenticated else []
)
active_slot = active_seat.slot_number if active_seat else None
ctx = {
"card_stack_state": card_stack_state,
"starter_roles": starter_roles,
"assigned_seats": assigned_seats,
"user_seat": user_seat,
"user_slots": list(
room.table_seats.filter(gamer=user, role__isnull=True)
.order_by("slot_number")
.values_list("slot_number", flat=True)
) if user.is_authenticated else [],
"active_slot": active_slot,
}
if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
ctx["user_seat"] = user_seat
ctx["partner_seat"] = partner_seat
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
return ctx
@login_required
def create_room(request):
if request.method == "POST":
name = request.POST.get("name", "").strip()
if name:
room = Room.objects.create(name=name, owner=request.user)
return redirect("epic:gatekeeper", room_id=room.id)
return redirect("/gameboard/")
def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id)
if room.table_status:
ctx = _role_select_context(room, request.user)
else:
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/room.html", ctx)
@login_required
def drop_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
token_id = request.POST.get("token_id")
if token_id:
token = request.user.tokens.filter(id=token_id).first()
else:
token = select_token(request.user)
if token is None:
return HttpResponse(status=402)
if token.token_type == Token.CARTE:
# CARTE enters the machine without reserving a slot — all slots
# become individually claimable via .drop-token-btn
token.current_room = room
token.save()
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
if slot:
slot.gamer = request.user
slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now()
slot.save()
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def confirm_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot_number = request.POST.get("slot_number")
if slot_number:
# CARTE per-slot fill: directly fill the requested slot
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
slot = room.gate_slots.filter(
slot_number=slot_number, status=GateSlot.EMPTY
).first()
if slot:
debit_token(request.user, slot, carte)
# slots_claimed is the high-water mark — advance if beyond current
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(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token_id = request.session.pop("kit_token_id", None)
token = None
if token_id:
token = request.user.tokens.filter(id=token_id).first()
if not token:
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)
@login_required
def return_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
# CARTE full return: reset token + all CARTE-debited slots
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
room.gate_slots.filter(
debited_token_type=Token.CARTE, gamer=request.user
).update(
gamer=None, status=GateSlot.EMPTY, filled_at=None,
debited_token_type=None, debited_token_expires_at=None,
)
carte.current_room = None
carte.slots_claimed = 0
carte.save()
request.session.pop("kit_token_id", None)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first()
if slot:
if slot.status == GateSlot.FILLED:
if slot.debited_token_type == Token.COIN:
coin = request.user.tokens.filter(
token_type=Token.COIN, current_room=room
).first()
if coin:
coin.current_room = None
coin.next_ready_at = None
coin.save()
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
Token.objects.create(
user=request.user,
token_type=slot.debited_token_type,
expires_at=slot.debited_token_expires_at,
)
request.session.pop("kit_token_id", None)
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.reserved_at = None
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def release_slot(request, room_id):
"""Un-fill a single CARTE-claimed slot without returning the CARTE itself."""
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot_number = request.POST.get("slot_number")
if slot_number:
slot = room.gate_slots.filter(
slot_number=slot_number,
debited_token_type=Token.CARTE,
gamer=request.user,
status=GateSlot.FILLED,
).first()
if slot:
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
if room.gate_status == Room.OPEN:
room.gate_status = Room.GATHERING
room.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def select_role(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.table_status != Room.ROLE_SELECT:
return redirect("epic:gatekeeper", room_id=room_id)
role = request.POST.get("role")
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles:
return redirect("epic:gatekeeper", room_id=room_id)
with transaction.atomic():
active_seat = room.table_seats.select_for_update().filter(
role__isnull=True
).order_by("slot_number").first()
if not active_seat or active_seat.gamer != request.user:
return redirect("epic:gatekeeper", room_id=room_id)
if room.table_seats.filter(role=role).exists():
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)
@login_required
def pick_roles(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN:
room.table_status = Room.ROLE_SELECT
room.save()
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
TableSeat.objects.create(
room=room,
gamer=slot.gamer,
slot_number=slot.slot_number,
)
_notify_role_select_start(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def invite_gamer(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
email = request.POST.get("invitee_email", "").strip()
if email:
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=email,
defaults={"status": RoomInvite.PENDING}
)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def delete_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if request.user == room.owner:
room.delete()
return redirect("/gameboard/")
@login_required
def abandon_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
room.gate_slots.filter(gamer=request.user).update(
gamer=None, status="EMPTY", filled_at=None
)
room.invites.filter(
invitee_email=request.user.email,
status=RoomInvite.PENDING
).delete()
return redirect("/gameboard/")
def gate_status(request, room_id):
room = Room.objects.get(id=room_id)
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)