import json from django.contrib.auth.decorators import login_required from django.http import HttpResponse, HttpResponseForbidden, 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, latest_draw_slots, _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 band = request.user.tokens.filter(token_type=Token.BAND).first() 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, "band": band, "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)), "my_sea_slots": latest_draw_slots(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, "band": request.user.tokens.filter(token_type=Token.BAND).first(), "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)), "my_sea_slots": latest_draw_slots(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 band = user.tokens.filter(token_type=Token.BAND).first() 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, "band": band, "carte": carte, "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, # `free_count` / `tithe_count` mirror the gameboard Game Kit applet's # context — drive the `(×N)` chip in the .tt-title plus the rendered # `.shop-badge` on the icon. Both capped to "99+" at the template # level so a runaway count doesn't blow the badge layout. "free_count": len(free_tokens), "tithe_count": len(tithe_tokens), # `deck_variants` is the annotated list (carries .in_use_room_name # per `_annotate_deck_in_use`); the gk-decks section uses the same # SVG card-stack icon + tt-tooltip pattern as the gameboard's Game # Kit applet so the deck row reads identically on both surfaces. "deck_variants": _annotate_deck_in_use(list(user.unlocked_decks.all()), user), "equipped_deck_id": user.equipped_deck_id, # `equipped_trinket_id` powers the gk-trinkets section's DON/DOFF # buttons + mini-portal Equipped status (parity w. gameboard applet). "equipped_trinket_id": user.equipped_trinket_id, "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") def _saved_by_position(saved_hand): """Build the per-position saved-card lookup the picker cross renders from (`_my_sea_slot.html`). Keyed by position slug ("lay", "cover", ...); each value carries the pre-resolved card fields so the template never hits the DB per slot. Shared by the owner picker (`my_sea`) AND the read-only spectator render (`my_sea_visit`) so both draw the IDENTICAL cross.""" saved = {} if not saved_hand: return saved 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[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 "", "has_card_images": (c.deck_variant.has_card_images if c and c.deck_variant else False), "image_url": c.image_url if c else "", "arcana": c.arcana if c else "", "name": c.name if c else "", } return saved @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 hand_complete = active_draw.is_hand_complete hand_empty = active_draw.is_hand_empty else: default_spread = "situation-action-outcome" saved_hand = [] hand_complete = False hand_empty = True # Brief banner's "next free draw at" — prefer the User's cooldown # anchor (`User.last_free_draw_at + 24h`, set on the first card of # the FREE DRAW path; persists across PAID DRAW commits per user- # spec 2026-05-23). Falls back to the active row's own # `next_free_draw_at` for legacy rows (or test fixtures that bypass # `my_sea_lock`). next_free_draw_at = ( request.user.next_free_draw_at or (active_draw.next_free_draw_at if active_draw is not None else None) ) # Sprint 6 iter 6b + 2026-05-23 fix — landing center-btn state machine. # The user is "in cooldown" iff a `MySeaDraw` row exists (the row was # created at first card-draw of the cycle + survives PAID DRAW commit). # Within cooldown: # # deposit reserved (at gatekeeper) → PAID DRAW (commits + picker) # paid-through credit set → PAID DRAW (navigates + picker) # neither → GATE VIEW # # Outside cooldown (no row): → FREE DRAW # # The two PAID DRAW states share one button label so the user sees a # stable "you're in a paid cycle" cue across navigation — user- # reported bug 2026-05-23: PAID DRAW used to revert to FREE DRAW # after the row was deleted at commit time. deposit_reserved = ( active_draw is not None and active_draw.deposit_token_id is not None ) paid_through = ( active_draw is not None and active_draw.paid_through_at is not None ) in_cooldown = active_draw is not None show_paid_draw = in_cooldown and (deposit_reserved or paid_through) show_gate_view = in_cooldown and not show_paid_draw hand_non_empty = active_draw is not None and bool(active_draw.hand) # Picker is the active phase iff: # - the user has a non-empty hand in progress / complete, OR # - `?phase=picker` query param is set AND the user is in a paid # cycle (deposit reserved OR paid-through credit set) — covers # the `my_sea_paid_draw` redirect + lets the PAID DRAW landing # button send the user back to the picker via a GET. # `?phase=landing` is the explicit ESCAPE HATCH: it forces the # landing template even when a non-empty hand exists, so the gear- # menu NVM (mid-draw) can dump the user back to the table hex # instead of just looping them back into the picker. The landing # then renders a CONT DRAW btn that re-enters the picker. phase_param = request.GET.get("phase") == "picker" force_landing = request.GET.get("phase") == "landing" show_picker = (hand_non_empty or (phase_param and show_paid_draw)) \ and not force_landing show_cont_draw = ( force_landing and active_draw is not None and bool(active_draw.hand) # at least 1 card drawn and not active_draw.is_hand_complete # but not all ) # 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. # Per-position lookup for `_my_sea_slot.html` — see `_saved_by_position` # (shared with the read-only spectator render so both draw the same cross). saved_by_position = _saved_by_position(saved_hand) # @taxman Brief payloads w. NVM-persistence (user-spec 2026-05-26). The # FREE DRAW Brief surfaces ONLY when an active draw exists AND the user # hasn't NVM-dismissed since the cycle began. PAID DRAW Brief is # independent — surfaces while a PAID DRAW commit has happened in this # cycle (paid_through_at OR a prior commit) AND the paid-draw NVM # anchor is older than the latest paid-draw brief. from django.urls import reverse free_draw_brief_payload = None paid_draw_brief_payload = None if active_draw is not None: free_draw_brief_payload = _tax_brief_payload( request.user, "FREE DRAW", request.user.free_draw_brief_dismissed_at, reverse("my_sea_dismiss_free_draw_brief"), ) paid_draw_brief_payload = _tax_brief_payload( request.user, "PAID DRAW", request.user.paid_draw_brief_dismissed_at, reverse("my_sea_dismiss_paid_draw_brief"), ) # Phase C — the owner's voice btn lights up while any present invitee # has an open 24h voice window. Room key is mysea-. from .models import SeaInvite voice_active = SeaInvite.objects.filter( owner=request.user, status=SeaInvite.ACCEPTED, token_deposited_at__isnull=False, left_at__isnull=True, voice_until__gt=timezone.now(), ).exists() return render(request, "apps/gameboard/my_sea.html", { "user_has_sig": user_has_sig, "voice_active": voice_active, "voice_room_id": f"mysea-{request.user.id}", "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, # Owner seated in 1C whenever committed to a draw cycle — paid for one # (deposit reserved OR paid-through credit, via show_paid_draw) OR has # cards down (hand_non_empty). Drives the persistent `.seated` + # `.fa-circle-check` on the landing's 1C chair (user-spec 2026-05-29); # a DEL'd row at GATE VIEW (empty hand, no paid credit) reads unseated. "seat1_seated": hand_non_empty or show_paid_draw, "show_picker": show_picker, "show_cont_draw": show_cont_draw, # Sub-btn .active flag for the burger fan — Sea sub-btn lights up # for the whole picker phase + STAYS active even after hand_complete # so the user can still open the modal to reach DEL + the GATE VIEW # btn (both live inside the modal). Earlier iteration tied this to # `show_picker and not hand_complete`; that locked DEL + GATE VIEW # behind an inactive btn once all cards landed. "sea_btn_active": show_picker, # Phase 3 of the Sea sub-btn rollout — pre-first-draw glow handoff. # True when picker phase is active + hand still empty (paid-draw # entry, or page reload of an empty picker). The fresh DRAW SEA # → picker transition happens client-side w. show_picker=False on # the rendered template, so the FREE-DRAW JS handler ALSO sets the # glow on burger in that path. "sea_first_draw_pending": show_picker and not hand_non_empty, "show_paid_draw": show_paid_draw, "show_gate_view": show_gate_view, "deposit_reserved": deposit_reserved, "paid_through": paid_through, "hand_non_empty": hand_non_empty, # TAX_LEDGER brief payloads (None when not eligible to show) "free_draw_brief_payload": free_draw_brief_payload, "paid_draw_brief_payload": paid_draw_brief_payload, "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 def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url): """Return `Brief.to_banner_dict()` (w. `dismiss_url` populated) for the user's latest TAX_LEDGER Brief whose Line text contains `slug_marker` (e.g. "FREE DRAW" / "PAID DRAW"), IF the user's `dismissed_at` anchor is None OR strictly less than the brief's `created_at`. Returns None otherwise — used by the my_sea view to drive conditional Brief render in the template w. NVM-persistence semantics per user-spec 2026-05-26. Slug-marker filtering on Line text keeps the Brief model schema flat (no sub_kind discriminator); FREE/PAID DRAW are the only two TAX_ LEDGER markers today + their wordings are non-overlapping.""" from apps.billboard.models import Brief brief = (Brief.objects .filter(owner=user, kind=Brief.KIND_TAX_LEDGER, line__text__contains=slug_marker) .order_by("-created_at").first()) if brief is None: return None if dismissed_at is not None and dismissed_at >= brief.created_at: return None payload = brief.to_banner_dict() payload["dismiss_url"] = dismiss_url return payload def _notify_sea_draw(owner_id, hand): """Best-effort push of the owner's current hand to watching invitees (the my-sea spectate WS, `mysea_`) so they witness each card land without refreshing. Guarded — a missing/down channel layer must never break the solo draw, since the spectate is an enhancement, not a hard dependency.""" try: from asgiref.sync import async_to_sync from channels.layers import get_channel_layer layer = get_channel_layer() if layer is None: return async_to_sync(layer.group_send)( f"mysea_{owner_id}", {"type": "sea_draw", "hand": hand}, ) except Exception: pass def _my_sea_seats(owner): """Table-ring seat list for `owner`'s sea — owner 1C + present invitees 2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is {n, label, present, token, invitee_id?}; the per-viewer `is_self` is layered on by the caller. Shared by the spectator render (my_sea_visit) AND the live seat broadcast (`sea_seats`).""" from .models import SeaInvite, MY_SEA_MAX_VISITORS owner_draw = active_draw_for(owner) owner_seated = owner_draw is not None and ( bool(owner_draw.hand) or owner_draw.deposit_token_id is not None or owner_draw.paid_through_at is not None ) owner_token = (f"owner-{owner.id}-{owner_draw.id}" if owner_draw is not None else "") seats = [{"n": 1, "label": "1C", "present": owner_seated, "token": owner_token}] present = ( SeaInvite.objects .filter(owner=owner, status=SeaInvite.ACCEPTED, token_deposited_at__isnull=False, left_at__isnull=True) .order_by("token_deposited_at")[:MY_SEA_MAX_VISITORS] ) for idx, inv in enumerate(present): seats.append({"n": idx + 2, "label": f"{idx + 2}C", "present": True, "token": f"visit-{inv.id}", "invitee_id": str(inv.invitee_id)}) while len(seats) < 6: n = len(seats) + 1 seats.append({"n": n, "label": f"{n}C", "present": False, "token": ""}) return seats def _notify_sea_seats(owner_id): """Best-effort push of the seat ring to watching invitees when presence changes (a deposit takes a seat / a BYE frees one) so the hex updates live. Guarded, same as `_notify_sea_draw`.""" try: from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from apps.lyric.models import User layer = get_channel_layer() if layer is None: return owner = User.objects.filter(id=owner_id).first() if owner is None: return async_to_sync(layer.group_send)( f"mysea_{owner_id}", {"type": "sea_seats", "seats": _my_sea_seats(owner)}, ) except Exception: pass @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 policy (refined 2026-05-25 PM per user bug report — # AUTO DRAW failing on non-SAO spreads with 409): the spread is # committed at first-card moment for the duration of the active # NON-EMPTY draw. Once the user DELs (clears hand to []) the row # stays alive for its 24h quota window but the spread lock lifts — # the user can pick a fresh spread for the next draw without losing # the cooldown clock. Mid-non-empty-draw spread switches still get # 409 to prevent the "sneaky POST" of a different spread without an # explicit DEL gesture. spread_changed = existing.spread != spread if spread_changed and existing.hand: return JsonResponse({"error": "spread_mismatch"}, status=409) # If this row carried a paid-through credit (set by `my_sea_paid_ # draw` at commit time) AND we're transitioning empty→non-empty, # the credit is being consumed by this draw — clear it so the # next attempt requires a fresh gatekeeper deposit (user-spec # 2026-05-23: "each redraw needs a new token"). was_empty = not existing.hand existing.hand = hand update_fields = ["hand"] if spread_changed: existing.spread = spread update_fields.append("spread") if (was_empty and hand and existing.paid_through_at is not None): existing.paid_through_at = None update_fields.append("paid_through_at") existing.save(update_fields=update_fields) _notify_sea_draw(request.user.id, existing.hand) # live to spectators return JsonResponse({ "ok": True, "next_free_draw_at": ( request.user.next_free_draw_at.isoformat() if request.user.next_free_draw_at else None ), "hand_complete": existing.is_hand_complete, }) # First card draw of a fresh cycle (no row exists). If the user's # free-draw cooldown isn't active, this is a FREE DRAW — anchor the # 24h cooldown to the User now (NOT to the row's created_at, per # user-spec 2026-05-23: the cooldown stays put even across PAID # DRAWs in the same cycle). sig_id = request.user.significator_id if sig_id is None: return JsonResponse({"error": "no_significator"}, status=400) if not request.user.free_draw_cooldown_active: request.user.last_free_draw_at = timezone.now() # Fresh FREE DRAW cycle → clear the NVM-dismissal anchor so the # newly-emitted Brief surfaces on the next page load. User-spec # 2026-05-26: NVM dismissal persists ONLY until the next FREE # DRAW spend; once that lands, the new ledger entry re-opens the # Brief surface for the new cycle. request.user.free_draw_brief_dismissed_at = None request.user.save(update_fields=[ "last_free_draw_at", "free_draw_brief_dismissed_at", ]) draw = MySeaDraw.objects.create( user=request.user, spread=spread, hand=hand, significator_id=sig_id, significator_reversed=request.user.significator_reversed, ) _notify_sea_draw(request.user.id, draw.hand) # live to spectators # Append the @taxman ledger entry + spawn the Brief. Response carries # the Brief payload so the picker IIFE can surface the banner in-place # w.o. a page reload — same affordance the prior in-template # `_showFreeDrawLockedBrief` provided, just w. server-authored copy + # NVM-persistence via `dismiss_url`. from django.urls import reverse from apps.billboard.tax import log_tax_debit _, _, brief = log_tax_debit(request.user, "free_draw_locked") brief_payload = brief.to_banner_dict() brief_payload["dismiss_url"] = reverse("my_sea_dismiss_free_draw_brief") return JsonResponse({ "ok": True, "next_free_draw_at": ( request.user.next_free_draw_at.isoformat() if request.user.next_free_draw_at else None ), "hand_complete": draw.is_hand_complete, "free_draw_brief_payload": brief_payload, }) @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"]) _notify_sea_draw(request.user.id, []) # clear the spectators' cross 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 + mark the row as paid-through so the PAID DRAW button label persists if the user navigates away before drawing cards (user-reported bug 2026-05-23: PAID DRAW was reverting to FREE DRAW after one navigation cycle because the row was deleted at commit time, wiping the cooldown state). Semantics: - debit_my_sea_token consumes the deposited token (FREE/TITHE deleted; COIN: 24h cooldown + unequip; PASS/BAND: no-op). - `deposit_token_id` + `deposit_reserved_at` cleared (token spent, no longer reserved). - `paid_through_at = now` — sticky credit marker. Drives the landing-button logic in `my_sea` (PAID DRAW button stays so the user can re-enter the picker without another gatekeeper visit as long as `hand` stays empty). - `hand = []` — fresh start per user-spec 2026-05-23 ("clear hand on PAID DRAW commit"). - `User.last_free_draw_at` is NOT touched. The 24h cooldown stays anchored to the original FREE DRAW moment (NOT the paid draw). Redirects to /gameboard/my-sea/?phase=picker so the user lands directly in the picker after the commit. """ from django.urls import reverse from apps.lyric.models import Token active_draw = active_draw_for(request.user) if active_draw is None: return redirect("my_sea") # Paid-through credit already set (no deposit currently reserved) — # this is the user clicking PAID DRAW on the landing AFTER an earlier # commit, to re-enter the picker. No token debit, just route to the # picker (the `paid_through_at` credit stays until the first card # lock consumes it in `my_sea_lock`). if active_draw.deposit_token_id is None: if active_draw.paid_through_at is not None: return redirect(reverse("my_sea") + "?phase=picker") 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.deposit_token_id = None active_draw.deposit_reserved_at = None active_draw.paid_through_at = timezone.now() active_draw.hand = [] active_draw.save(update_fields=[ "deposit_token_id", "deposit_reserved_at", "paid_through_at", "hand", ]) # Fresh PAID DRAW commit → clear the PAID DRAW NVM-dismissal anchor + # append the @taxman ledger entry / spawn the Brief. Per user-spec # 2026-05-26 the PAID DRAW Brief NVM-persistence is independent from # FREE DRAW's; each cycle's dismissal lifts on its own next-spend. request.user.paid_draw_brief_dismissed_at = None request.user.save(update_fields=["paid_draw_brief_dismissed_at"]) from apps.billboard.tax import log_tax_debit log_tax_debit(request.user, "paid_draw_locked") return redirect(reverse("my_sea") + "?phase=picker") @login_required(login_url="/") @require_POST def my_sea_dismiss_free_draw_brief(request): """Stamp `User.free_draw_brief_dismissed_at` so the FREE DRAW Brief stays suppressed on subsequent page loads until the next FREE DRAW is spent (`my_sea_lock` clears the anchor on a fresh-cycle commit, re- opening the Brief surface for the new cycle). User-spec 2026-05-26: NVM-persistence for the FREE DRAW Brief. Fire- and-forget POST from `note.js`'s NVM click handler; returns 204.""" request.user.free_draw_brief_dismissed_at = timezone.now() request.user.save(update_fields=["free_draw_brief_dismissed_at"]) return HttpResponse(status=204) @login_required(login_url="/") @require_POST def my_sea_dismiss_paid_draw_brief(request): """Stamp `User.paid_draw_brief_dismissed_at` so the PAID DRAW Brief stays suppressed on subsequent page loads until the next PAID DRAW commit. Mirror of `my_sea_dismiss_free_draw_brief`.""" request.user.paid_draw_brief_dismissed_at = timezone.now() request.user.save(update_fields=["paid_draw_brief_dismissed_at"]) return HttpResponse(status=204) @login_required(login_url="/") @require_POST def my_sea_invite(request): """Invite a bud to the owner's my-sea table (Phase A of [[my-sea-invite-voice-blueprint]] — replaces the iter-6c "coming soon" stub). Resolves the recipient (email OR username) to a registered User when possible, dedups against an outstanding PENDING/ACCEPTED invite for the same (owner, invitee_email), creates a SeaInvite(PENDING), and logs the @mailman "Acceptances & rejections" Line + invitee Brief via `apps.billboard.mail.log_sea_invite`. Returns JSON `{brief, recipient_display}` for the inviter's sent- confirmation banner (bud-btn.js `onSuccess` renders it). The inviter's confirmation is a transient banner; the invitee's notification IS persisted (log_sea_invite spawns the Brief).""" from django.urls import reverse from apps.billboard.mail import log_sea_invite from apps.billboard.views import _resolve_recipient from .models import SeaInvite raw = (request.POST.get("recipient") or "").strip() if not raw: return JsonResponse({"brief": None, "recipient_display": None}) candidate = _resolve_recipient(raw) if candidate is not None and candidate == request.user: return JsonResponse({"brief": None, "recipient_display": None}) # no self-invite invitee_email = candidate.email if candidate else raw recipient_display = ( (candidate.username or candidate.email) if candidate else raw ) # Dedup: an outstanding (PENDING/ACCEPTED) invite for this recipient is # "already invited". Terminal-state invites (DECLINED/EXPIRED/LEFT) don't # block a fresh re-invite. already = SeaInvite.objects.filter( owner=request.user, invitee_email=invitee_email, status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED], ).exists() if already: return JsonResponse({ "brief": { "title": "Already invited", "line_text": f"Look!—{recipient_display} is already invited to your Sea.", "post_url": reverse("my_sea"), "created_at": "", "kind": "NUDGE", }, "recipient_display": recipient_display, }) invite = SeaInvite.objects.create( owner=request.user, invitee=candidate, invitee_email=invitee_email, status=SeaInvite.PENDING, ) log_sea_invite(invite) return JsonResponse({ "brief": { "title": "Invite sent", "line_text": f"Look!—your invite is on its way to {recipient_display}.", "post_url": reverse("my_sea"), "created_at": "", "kind": "NUDGE", }, "recipient_display": recipient_display, }) # Explicit accept/decline endpoints (the @mailman Post's OK/BYE forms + # `_invite_actions.html`) were removed 2026-05-29: acceptance is now implicit # — clicking the bud-page sea sub-btn lands on `my_sea_visit`, which accepts a # still-pending invite on GET. There is no decline surface; an un-clicked # invite simply lapses after 24h (`SeaInvite.is_expired`). # ── Phase B — my-sea spectator (invitee) surfaces ─────────────────────────── def _accepted_visit_invite(owner, user): """The requester's ACCEPTED invite to spectate `owner`'s my-sea, or None. The single access gate for every spectator surface — a token deposit keeps the row ACCEPTED (presence derives from `token_deposited_at`); a BYE flips it to LEFT, which closes the gate.""" from .models import SeaInvite return SeaInvite.objects.filter( owner=owner, invitee=user, status=SeaInvite.ACCEPTED, ).order_by("-created_at").first() @login_required(login_url="/") def my_sea_visit(request, owner_id): """Spectator view — an ACCEPTED invitee watches the owner's my-sea read- only (Phase B of [[my-sea-invite-voice-blueprint]]). 403 unless an ACCEPTED SeaInvite(owner, request.user) exists; the owner is bounced to their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C = this visitor once present) + the owner's draw read-only via `latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's hand — `sea_btn_active` forced False. Accept-on-GET (user-spec 2026-05-29): a still-pending, non-expired invite from `owner` to this user is accepted implicitly on arrival — the bud-page sea-btn cascade (+ the @mailman post-attribution anchor) both land here, and the click IS the acceptance. A stranger with no invite still 403s.""" from apps.lyric.models import User from .models import SeaInvite, MY_SEA_MAX_VISITORS owner = get_object_or_404(User, id=owner_id) if owner == request.user: return redirect("my_sea") pending = ( SeaInvite.objects .filter(owner=owner, invitee=request.user, status=SeaInvite.PENDING) .order_by("-created_at").first() ) if pending is not None and not pending.is_expired: pending.status = SeaInvite.ACCEPTED pending.accepted_at = timezone.now() pending.save(update_fields=["status", "accepted_at"]) invite = _accepted_visit_invite(owner, request.user) if invite is None: return HttpResponseForbidden() owner_draw = active_draw_for(owner) sig_card, sig_reversed = _resolve_sig(owner, owner_draw) owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand) # Owner seated in 1C (as the visitor sees it) whenever she's committed to a # draw cycle — drawn OR paid (deposit reserved / paid-through credit). Keeps # the owner's 1C chair persistently `.seated` on the spectator hex even # before a card lands (user-spec 2026-05-29); mirrors my_sea's seat1_seated. owner_paid = owner_draw is not None and ( owner_draw.deposit_token_id is not None or owner_draw.paid_through_at is not None ) owner_seated = owner_hand_non_empty or owner_paid # Multi-seat hex (2026-05-29): every present member shows on the ring — # owner 1C + present invitees 2C-6C by deposit order (the same seats # everyone sees), built by the shared `_my_sea_seats` helper that the live # `sea_seats` broadcast also uses. Layer the per-viewer `is_self` on here. seats = _my_sea_seats(owner) for seat in seats: seat["is_self"] = seat.get("invitee_id") == str(request.user.id) # Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's # VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the # owner sees, populated from the owner's draw. `saved_by_position` fills # the slots; `label_by_position` captions them per the owner's spread; # `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked # slot's full card face for the magnified stage. owner_slots = latest_draw_slots(owner) return render(request, "apps/gameboard/my_sea_visit.html", { "spectator": True, "is_owner": False, "read_only": True, "owner": owner, "sea_invite": invite, "seat1_present": owner_seated, "seat2_present": invite.is_present, "seats": seats, "owner_draw_id": owner_draw.id if owner_draw is not None else "", "voice_active": invite.voice_active, "voice_room_id": f"mysea-{owner.id}", "significator": sig_card, "significator_reversed": sig_reversed, "my_sea_slots": owner_slots, "owner_hand_non_empty": owner_hand_non_empty, # Read-only cross-stage parity payload. "default_spread": (owner_draw.spread if owner_draw is not None else "situation-action-outcome"), "saved_by_position": _saved_by_position( owner_draw.hand if owner_draw is not None else []), "label_by_position": {s["position"]: s["label"] for s in owner_slots}, "sea_deck_data": ( _my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None) if sig_card is not None else {"levity": [], "gravity": []} ), # Owner-only controls forced off on the spectator surface. "sea_btn_active": False, "sea_first_draw_pending": False, "page_class": "page-gameboard page-my-sea page-my-sea-visit", }) @login_required(login_url="/") def my_sea_visit_gate(request, owner_id): """Visitor gate — single-step token deposit to occupy seat 2C + open the voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN only; no refund / PAID-DRAW two-step).""" from apps.lyric.models import User from .models import SeaInvite owner = get_object_or_404(User, id=owner_id) invite = _accepted_visit_invite(owner, request.user) if invite is None: return HttpResponseForbidden() sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner)) # Seat cap — a not-yet-present visitor can't deposit into a full table # (owner + 5). The gate renders a "table full" notice instead of the rails. table_full = not invite.is_present and not SeaInvite.table_has_room(owner) return render(request, "apps/gameboard/my_sea_gate.html", { "spectator": True, "owner": owner, "sea_invite": invite, "visit_owner_id": owner.id, "user_has_sig": sig_card is not None, "significator": sig_card, "significator_reversed": sig_reversed, # Visitor gate is single-step — no reserve/commit, so the refund + # PAID DRAW blocks (gated on `deposit_reserved`) never render. "deposit_reserved": False, "hand_non_empty": False, "table_full": table_full, "page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate", }) @login_required(login_url="/") @require_POST def my_sea_visit_insert_token(request, owner_id): """Single-step visitor token deposit. Selects + debits the visitor's next- priority token (same priority chain as the owner gate), then records `token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the owner's MySeaDraw), marking seat 2C present + opening the voice window.""" from datetime import timedelta from django.urls import reverse from apps.lyric.models import User from .models import SeaInvite owner = get_object_or_404(User, id=owner_id) invite = _accepted_visit_invite(owner, request.user) if invite is None: return HttpResponseForbidden() if invite.token_deposited_at is None: # Seat cap (user-spec 2026-05-29): owner (1C) + up to 5 visitors. A # fresh deposit can only take a seat while one is free; a full table # bounces back w. ?full=1 so the gate can say so. A visitor who LEFT # frees their seat for someone else (is_present → False). if not SeaInvite.table_has_room(owner): return redirect( reverse("my_sea_visit", args=[owner.id]) + "?full=1") token = _select_my_sea_token(request.user) if token is not None: debit_my_sea_token(request.user, token) now = timezone.now() invite.token_deposited_at = now invite.voice_until = now + timedelta(hours=24) invite.save(update_fields=["token_deposited_at", "voice_until"]) _notify_sea_seats(owner.id) # live: this visitor takes a seat return redirect("my_sea_visit", owner_id=owner.id) @login_required(login_url="/") @require_POST def my_sea_visit_leave(request, owner_id): """Spectator BYE — drop presence: status=LEFT, left_at=now, voice killed (frees seat 2C + ends the voice window). Matches the requester's latest non-DECLINED invite for this owner.""" from apps.lyric.models import User from .models import SeaInvite owner = get_object_or_404(User, id=owner_id) invite = (SeaInvite.objects .filter(owner=owner, invitee=request.user) .exclude(status=SeaInvite.DECLINED) .order_by("-created_at").first()) if invite is None: return HttpResponseForbidden() invite.status = SeaInvite.LEFT invite.left_at = timezone.now() invite.voice_until = None invite.save(update_fields=["status", "left_at", "voice_until"]) _notify_sea_seats(owner.id) # live: this visitor frees their seat return redirect("gameboard") 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} 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, })