Files
python-tdd/src/apps/gameboard/views.py
Disco DeDisco d0c39b51b6 my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD
Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED
invitee can watch the owner's my-sea read-only, deposit a token to occupy
seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's
my_sea.html is left structurally intact — the spectator gets a dedicated,
simpler my_sea_visit.html; the read-only draw reuses the existing
`latest_draw_slots` payload (no picker surgery).

- B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED
  SeaInvite(owner, request.user); owner bounced to their own my_sea. Context
  forces owner-only controls off (sea_btn_active=False, read_only=True);
  renders the table hex (1C owner / 2C visitor) + owner draw read-only.
- B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a
  spectator branch (titles the OWNER's Sea, INSERT posts to the visitor
  endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step
  my_sea_visit_insert_token selects+debits the visitor's token (same
  priority chain) and records token_deposited_at + a 24h voice_until on the
  SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW.
- B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at,
  clears voice_until (frees 2C, ends voice), redirects /gameboard/.
  _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages
  pass no leave_url, so unchanged).
- B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared
  apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a
  per-occupancy data-seat-token) an occupied seat flares --terUser +
  --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban
  already swapped to .fa-circle-check). _room.scss adds .seated /
  .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's
  .active→.role-confirmed handoff). Wired on BOTH the spectator page (load)
  and the owner page (load + on the FREE DRAW seat-1 transition).
  MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal.
- B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit →
  VIEW DRAW + seat 2C seated.

URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT
green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice
+ coturn droplet) next — the 24h voice_until window set here drives it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:35:00 -04:00

1042 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
@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-<name>` 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 "",
# Sprint A.7-polish: extra fields for image-mode slot render
# in `_my_sea_slot.html`. Empty strings when the card's deck
# has no images (legacy text-only); template branches on
# `has_card_images` to pick render mode.
"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 "",
}
# @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"),
)
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,
"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
@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": "<slug>", "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)
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,
)
# 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"])
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,
})
def _sea_invite_for_request(request, invite_id):
"""Fetch a SeaInvite + decide whether the requester is its invitee. Matches
on the invitee FK when set, else on email (handles an invite created
before the recipient registered). Returns ``(invite, is_invitee)``."""
from .models import SeaInvite
invite = get_object_or_404(SeaInvite, id=invite_id)
if invite.invitee_id is not None:
is_invitee = invite.invitee_id == request.user.id
else:
is_invitee = (
(invite.invitee_email or "").lower() == (request.user.email or "").lower()
)
return invite, is_invitee
def _redirect_to_invite_log(invite):
"""Redirect to the invitee's "Acceptances & rejections" Post (where the
invite Line lives) so the OK/BYE line re-renders post-transition. Falls
back to /gameboard/ if the invite has no linked line/post yet."""
from django.urls import reverse
if invite.line_id and invite.line.post_id:
return redirect(reverse("billboard:view_post", args=[invite.line.post_id]))
return redirect("gameboard")
@login_required(login_url="/")
@require_POST
def my_sea_invite_accept(request, invite_id):
"""Invitee accepts a PENDING my-sea invite → ACCEPTED. Links the invitee
FK + stamps accepted_at. Phase B will redirect to the owner's spectator
table (`my_sea_visit`); for now we redirect back to the invite log Post so
the line re-renders with its Accepted badge."""
from .models import SeaInvite
invite, is_invitee = _sea_invite_for_request(request, invite_id)
if not is_invitee:
return HttpResponseForbidden()
if invite.status == SeaInvite.PENDING and not invite.is_expired:
invite.status = SeaInvite.ACCEPTED
invite.accepted_at = timezone.now()
invite.invitee = request.user
invite.save(update_fields=["status", "accepted_at", "invitee"])
return _redirect_to_invite_log(invite)
@login_required(login_url="/")
@require_POST
def my_sea_invite_decline(request, invite_id):
"""Invitee declines a PENDING my-sea invite → DECLINED."""
from .models import SeaInvite
invite, is_invitee = _sea_invite_for_request(request, invite_id)
if not is_invitee:
return HttpResponseForbidden()
if invite.status == SeaInvite.PENDING:
invite.status = SeaInvite.DECLINED
invite.save(update_fields=["status"])
return _redirect_to_invite_log(invite)
# ── 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."""
from apps.lyric.models import User
owner = get_object_or_404(User, id=owner_id)
if owner == request.user:
return redirect("my_sea")
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)
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_hand_non_empty,
"seat2_present": invite.is_present,
"owner_draw_id": owner_draw.id if owner_draw is not None else "",
"voice_active": invite.voice_active,
"significator": sig_card,
"significator_reversed": sig_reversed,
"my_sea_slots": latest_draw_slots(owner),
"owner_hand_non_empty": owner_hand_non_empty,
# 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
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))
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,
"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 apps.lyric.models import User
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:
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"])
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"])
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,
})