After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec).
DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free.
Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/.
The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow.
View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching.
- 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay.
- 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence).
Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
216 lines
8.5 KiB
Python
216 lines
8.5 KiB
Python
from django.contrib.auth.decorators import login_required
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils import timezone
|
|
|
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
|
|
|
|
|
def _annotate_deck_in_use(decks, user):
|
|
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
|
|
active = {
|
|
ts.deck_variant_id: ts.room.name
|
|
for ts in TableSeat.objects.filter(
|
|
gamer=user, deck_variant__isnull=False,
|
|
).select_related("room")
|
|
}
|
|
for deck in decks:
|
|
deck.in_use_room_name = active.get(deck.pk)
|
|
return decks
|
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
|
from apps.epic.utils import annotate_latest_event, rooms_for_user
|
|
from apps.lyric.models import Token
|
|
|
|
|
|
GAMEBOARD_APPLET_ORDER = [
|
|
"new-game",
|
|
"my-games",
|
|
"game-kit",
|
|
]
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def gameboard(request):
|
|
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
|
coin = request.user.tokens.filter(token_type=Token.COIN).first()
|
|
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
|
|
free_tokens = list(request.user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
return render(
|
|
request, "apps/gameboard/gameboard.html", {
|
|
"pass_token": pass_token,
|
|
"coin": coin,
|
|
"carte": carte,
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
|
"free_tokens": free_tokens,
|
|
"free_count": len(free_tokens),
|
|
"applets": applet_context(request.user, "gameboard"),
|
|
"page_class": "page-gameboard",
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
|
}
|
|
)
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_game_applets(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "gameboard", checked)
|
|
if request.headers.get("HX-Request"):
|
|
free_tokens = list(request.user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
return render(request, "apps/gameboard/_partials/_applets.html", {
|
|
"applets": applet_context(request.user, "gameboard"),
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
|
"free_tokens": free_tokens,
|
|
"free_count": len(free_tokens),
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
|
})
|
|
return redirect("gameboard")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def equip_trinket(request, token_id):
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
if request.method == "POST":
|
|
request.user.equipped_trinket = token
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
return HttpResponse(status=204)
|
|
return render(
|
|
request,
|
|
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
|
{"token": token},
|
|
)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def equip_deck(request, deck_id):
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
if request.method == "POST":
|
|
request.user.equipped_deck = deck
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def unequip_trinket(request, token_id):
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
if request.method == "POST":
|
|
if request.user.equipped_trinket_id == token.pk:
|
|
request.user.equipped_trinket = None
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def unequip_deck(request, deck_id):
|
|
get_object_or_404(DeckVariant, pk=deck_id)
|
|
if request.method == "POST":
|
|
if request.user.equipped_deck_id == deck_id:
|
|
request.user.equipped_deck = None
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
def _game_kit_context(user):
|
|
from apps.lyric.models import PRONOUN_CHOICES
|
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
|
free_tokens = list(user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
|
pronoun_options = [
|
|
{"key": k, "label": label, "active": (k == user.pronouns)}
|
|
for (k, label) in PRONOUN_CHOICES
|
|
]
|
|
return {
|
|
"coin": coin,
|
|
"pass_token": pass_token,
|
|
"carte": carte,
|
|
"free_tokens": free_tokens,
|
|
"tithe_tokens": tithe_tokens,
|
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
|
"applets": applet_context(user, "game-kit"),
|
|
"pronoun_options": pronoun_options,
|
|
"current_pronouns": user.pronouns,
|
|
}
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def game_kit(request):
|
|
return render(request, "apps/gameboard/game_kit.html", {
|
|
**_game_kit_context(request.user),
|
|
"page_class": "page-gameboard",
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_game_kit_sections(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "game-kit", checked)
|
|
if request.headers.get("HX-Request"):
|
|
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
|
_game_kit_context(request.user))
|
|
return redirect("game_kit")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def my_sea(request):
|
|
"""Shell view for the My Sea standalone page.
|
|
|
|
Branches three ways:
|
|
|
|
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b).
|
|
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w.
|
|
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps
|
|
data-phase to picker (the picker UX itself lands in iter 2).
|
|
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning'
|
|
Brief banner identical to the one on /billboard/my-sign/ (the user
|
|
is headed for a draw against the Earthman [Shabby Cardstock]
|
|
backup deck unless they equip one first).
|
|
"""
|
|
user_has_sig = request.user.significator_id is not None
|
|
no_equipped_deck = request.user.equipped_deck_id is None
|
|
return render(request, "apps/gameboard/my_sea.html", {
|
|
"user_has_sig": user_has_sig,
|
|
"no_equipped_deck": no_equipped_deck,
|
|
"show_backup_intro_banner": user_has_sig and no_equipped_deck,
|
|
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the
|
|
# picker phase. Template guards on `user_has_sig` so a None pass-
|
|
# through is safe; we pass the FK directly so `.corner_rank` +
|
|
# `.suit_icon` resolve at render time.
|
|
"significator": request.user.significator,
|
|
"significator_reversed": request.user.significator_reversed,
|
|
"page_class": "page-gameboard page-my-sea",
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def tarot_fan(request, deck_id):
|
|
from apps.epic.models import TarotCard
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
|
return HttpResponse(status=403)
|
|
_suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3,
|
|
"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3}
|
|
cards = sorted(
|
|
TarotCard.objects.filter(deck_variant=deck),
|
|
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
|
)
|
|
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
|
|
"deck": deck,
|
|
"cards": cards,
|
|
})
|