import json from django.contrib.auth.decorators import login_required from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_POST from apps.applets.utils import applet_context, apply_applet_toggle from .models import ( HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, _select_my_sea_token, debit_my_sea_token, ) 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. Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as hand storage AND 24h quota tracker. The row is created on first card draw + survives DEL (which only wipes the hand). View states: 1. No sig → Look!-formatted sign-gate (Sprint 4b). 2. Active draw, hand non-empty (mid-draw or complete) → picker phase w. saved hand state. The DEL btn is server-rendered `.btn- disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on completion. The draw's sig snapshot is rendered (NOT user. significator) so a cleared sig elsewhere doesn't invalidate the saved draw. 3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the free-draw quota is spent; user must use tokens via the upcoming Sprint 6 gatekeeper). Brief banner still surfaces the next- free-draw timestamp. 4. No active draw + sig set → landing w. FREE DRAW (the daily quota is available). 4a. + no equipped deck → also show backup-deck Brief banner. """ active_draw = active_draw_for(request.user) sig_card, sig_reversed = _resolve_sig(request.user, active_draw) user_has_sig = sig_card is not None no_equipped_deck = request.user.equipped_deck_id is None if active_draw is not None: default_spread = active_draw.spread saved_hand = active_draw.hand next_free_draw_at = active_draw.next_free_draw_at hand_complete = active_draw.is_hand_complete hand_empty = active_draw.is_hand_empty else: default_spread = "situation-action-outcome" saved_hand = [] next_free_draw_at = None hand_complete = False hand_empty = True # Picker is the active phase iff the user has a non-empty hand in # progress (or completed). Empty-hand active draws (post-DEL) fall # back to the landing — but render GATE VIEW instead of FREE DRAW # (the daily quota's spent already; landing's primary nav routes to # the upcoming gatekeeper). New users + post-24h users land on the # standard FREE DRAW landing. show_picker = active_draw is not None and not hand_empty quota_spent = active_draw is not None # any active row = quota committed # Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence. # `deposit_reserved` toggles the landing primary from GATE VIEW to # PAID DRAW (one-click commit of the already-deposited token). # `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads # don't lose the JS-only animation state. deposit_reserved = ( active_draw is not None and active_draw.deposit_token_id is not None ) hand_non_empty = active_draw is not None and bool(active_draw.hand) # Per-position lookup for the template — keyed by the position slug # ("lay", "cover", ...) so each `.sea-pos-` block can render # either its saved card OR an `--empty` slot via a single `{% with # entry=saved_by_position.lay %}` block. The card fields (corner_rank, # suit_icon) come pre-resolved so the template doesn't need to do a # DB lookup per slot. saved_by_position = {} if saved_hand: from apps.epic.models import TarotCard ids = [e["card_id"] for e in saved_hand] cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)} for entry in saved_hand: c = cards_by_id.get(entry["card_id"]) saved_by_position[entry["position"]] = { "card_id": entry["card_id"], "reversed": entry.get("reversed", False), "polarity": entry.get("polarity", "gravity"), "corner_rank": c.corner_rank if c else "", "suit_icon": c.suit_icon if c else "", } 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 and active_draw is None ), "significator": sig_card, "significator_reversed": sig_reversed, "default_spread": default_spread, "reversals_pct": 25, "sea_deck_data": ( _my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None) if user_has_sig else {"levity": [], "gravity": []} ), # Iter 4b / 4c "active_draw": active_draw, "saved_hand": saved_hand, "saved_by_position": saved_by_position, "next_free_draw_at": next_free_draw_at, "hand_complete": hand_complete, "show_picker": show_picker, "quota_spent": quota_spent, "deposit_reserved": deposit_reserved, "hand_non_empty": hand_non_empty, "page_class": "page-gameboard page-my-sea", }) def _resolve_sig(user, active_draw): """When an active draw exists, render its sig snapshot — even if user.significator has since been cleared (per user spec, preserve the old sig on the saved draw). Otherwise use user.significator.""" if active_draw is not None: from apps.epic.models import TarotCard sig = TarotCard.objects.filter(id=active_draw.significator_id).first() return sig, active_draw.significator_reversed return user.significator, user.significator_reversed @login_required(login_url="/") @require_POST def my_sea_lock(request): """Upsert the user's draw hand state. Sprint 5 iter 4c refactor — fires on every card placement (manual FLIP or AUTO DRAW completion) rather than only on a discrete LOCK HAND action. Body: JSON `{"spread": "", "hand": [{position, card_id, reversed, polarity}, ...]}` — `hand` is the current FULL state (partial OK for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete). Returns: 200 `{ok, next_free_draw_at, hand_complete}` on success 400 malformed payload or no sig 409 spread differs from the user's already-active draw's spread (the spread is locked at first-card moment; can't switch mid- draw via a sneaky POST) """ try: payload = json.loads(request.body.decode("utf-8") or "{}") except json.JSONDecodeError: return JsonResponse({"error": "invalid_json"}, status=400) spread = payload.get("spread") hand = payload.get("hand") if not spread or not isinstance(hand, list) or not hand: return JsonResponse({"error": "spread_and_hand_required"}, status=400) if spread not in HAND_SIZE_BY_SPREAD: return JsonResponse({"error": "unknown_spread"}, status=400) existing = active_draw_for(request.user) if existing is not None: # Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route # through the gatekeeper but the endpoint stays permissive here). # Spread-switch attempts get 409 — the spread is committed at # first-card moment. if existing.spread != spread: return JsonResponse({"error": "spread_mismatch"}, status=409) existing.hand = hand existing.save(update_fields=["hand"]) return JsonResponse({ "ok": True, "next_free_draw_at": existing.next_free_draw_at.isoformat(), "hand_complete": existing.is_hand_complete, }) # First card draw → quota commit. Create the row. sig_id = request.user.significator_id if sig_id is None: return JsonResponse({"error": "no_significator"}, status=400) draw = MySeaDraw.objects.create( user=request.user, spread=spread, hand=hand, significator_id=sig_id, significator_reversed=request.user.significator_reversed, ) return JsonResponse({ "ok": True, "next_free_draw_at": draw.next_free_draw_at.isoformat(), "hand_complete": draw.is_hand_complete, }) @login_required(login_url="/") @require_POST def my_sea_delete(request): """Clear the user's active draw hand — preserves the `MySeaDraw` row so the 24h quota window keeps running. Per user spec (2026-05-20): DEL doesn't refund the daily free-draw; the row stays as a quota tracker until 24h elapse, after which `active_draw_for`'s lazy cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does). Idempotent: re-firing on a row w. already-empty hand is a no-op.""" draw = active_draw_for(request.user) if draw is not None: draw.hand = [] draw.save(update_fields=["hand"]) return HttpResponse(status=204) @login_required(login_url="/") def my_sea_gate(request): """Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's `_gatekeeper.html` structure (coin-slot rails + refund affordance) adapted for 1-user, 1-token-per-redraw semantics. The user spends a token (PASS/COIN/FREE/TITHE — CARTE excluded) to acquire a fresh 24h quota cycle after their daily free draw is spent. Branches on `MySeaDraw.deposit_token_id`: - None (no deposit yet) → INSERT TOKEN TO PLAY rails are active. - non-None (deposit reserved) → refund affordance + PAID DRAW btn. """ active_draw = active_draw_for(request.user) sig_card, sig_reversed = _resolve_sig(request.user, active_draw) deposit_reserved = ( active_draw is not None and active_draw.deposit_token_id is not None ) hand_non_empty = active_draw is not None and bool(active_draw.hand) return render(request, "apps/gameboard/my_sea_gate.html", { "user_has_sig": sig_card is not None, "significator": sig_card, "significator_reversed": sig_reversed, "active_draw": active_draw, "deposit_reserved": deposit_reserved, "hand_non_empty": hand_non_empty, "page_class": "page-gameboard page-my-sea page-my-sea-gate", }) @login_required(login_url="/") @require_POST def my_sea_insert_token(request): """Reserve the user's next-priority token on their MySeaDraw row. Idempotent w.r.t. an already-reserved deposit — re-posting is a no-op rather than double-debit. Creates the row if none exists (so a fresh user can hit the gatekeeper without first using their free draw).""" active_draw = active_draw_for(request.user) if active_draw is None: # No active row yet — create a quota tracker row w. empty hand # so the deposit has something to attach to. This also commits # the user's free-draw quota for the day (since `active_draw_ # for` will now return this row). sig_id = request.user.significator_id if sig_id is None: return redirect("my_sea_gate") active_draw = MySeaDraw.objects.create( user=request.user, spread="situation-action-outcome", hand=[], significator_id=sig_id, significator_reversed=request.user.significator_reversed, ) if active_draw.deposit_token_id is not None: return redirect("my_sea_gate") token = _select_my_sea_token(request.user) if token is None: return redirect("my_sea_gate") active_draw.deposit_token_id = token.pk active_draw.deposit_reserved_at = timezone.now() active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) return redirect("my_sea_gate") @login_required(login_url="/") @require_POST def my_sea_refund_token(request): """Clear the user's deposit reservation. Token wasn't actually debited at INSERT (refund-aware design), so this is purely a row update — no side effects on the user's inventory.""" active_draw = active_draw_for(request.user) if active_draw is not None and active_draw.deposit_token_id is not None: active_draw.deposit_token_id = None active_draw.deposit_reserved_at = None active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) return redirect("my_sea_gate") @login_required(login_url="/") @require_POST def my_sea_paid_draw(request): """Commit the deposited token + reset the row for a fresh quota cycle. The token is debited via `debit_my_sea_token` (FREE/TITHE consumed; COIN 24h cooldown + unequipped; PASS no-op). Hand wiped, `created_at` reset to now, deposit fields cleared. User redirects back to /gameboard/my-sea/ ready to draw a fresh hand.""" from apps.lyric.models import Token active_draw = active_draw_for(request.user) if active_draw is None or active_draw.deposit_token_id is None: return redirect("my_sea") token = Token.objects.filter( pk=active_draw.deposit_token_id, user=request.user, ).first() if token is None: # Token vanished between reserve + commit (unlikely w. solo # flow but defensive). Clear deposit + bounce to my-sea. active_draw.deposit_token_id = None active_draw.deposit_reserved_at = None active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) return redirect("my_sea") debit_my_sea_token(request.user, token) active_draw.hand = [] active_draw.created_at = timezone.now() active_draw.deposit_token_id = None active_draw.deposit_reserved_at = None active_draw.save(update_fields=[ "hand", "created_at", "deposit_token_id", "deposit_reserved_at", ]) return redirect("my_sea") def _my_sea_deck_data(user, exclude_id=None): """Build the shuffled deck (levity + gravity halves) for the my-sea picker's card-draw mechanic. Card payload shape is whatever `apps.epic.utils.card_dict` defines (single source of truth shared w. the gameroom `sea_deck` endpoint). Differences from the room version: - No `room` context — exclude only the sig card (no other seated gamers to worry about). `exclude_id` defaults to `user.significator_id` but callers can pass a draw's snapshotted sig id when the saved- draw branch is rendering. - Backup-deck fallthrough: if the user's `equipped_deck` is None, fall back to Earthman (mirrors `personal_sig_cards`). """ import random from apps.epic.models import DeckVariant, TarotCard from apps.epic.utils import card_dict, stack_reversal_probability deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first() if not deck: return {"levity": [], "gravity": []} if exclude_id is None: exclude_id = user.significator_id available = list(TarotCard.objects.filter(deck_variant=deck)) if exclude_id: available = [c for c in available if c.id != exclude_id] random.shuffle(available) mid = len(available) // 2 reversal_prob = stack_reversal_probability(user) return { "levity": [card_dict(c, reversal_prob) for c in available[:mid]], "gravity": [card_dict(c, reversal_prob) for c in available[mid:]], } @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, })