Files
python-tdd/src/apps/gameboard/views.py
Disco DeDisco 0693a422d2 my-sea: second spectate broadcast — seat ring updates live on deposit / BYE — TDD
Extends the async-witness WS so a visitor joining (deposit) or leaving (BYE)
pushes the seat ring to the other watchers — they see members come + go without
a refresh, same channel as the live draw.

- views.py: `_my_sea_seats(owner)` extracted (owner 1C + present invitees 2C-6C
  by deposit order, sans per-viewer is_self) — used by BOTH the my_sea_visit
  render (layers is_self on) AND a new guarded `_notify_sea_seats(owner_id)`
  broadcast. Fired from my_sea_visit_insert_token (seat taken) +
  my_sea_visit_leave (seat freed).
- consumers.py: MySeaSpectateConsumer gains a `sea_seats` handler.
- my_sea_visit.html: the WS client re-renders the `.table-seat` ring from a
  `sea_seats` message, re-marking the viewer's own --self chair via the embedded
  seat token + re-firing the one-shot seated glow (localStorage-gated).

Tests: +1 channels relay IT (sea_seats received) + 2 view ITs (deposit / BYE
each broadcast the ring). Existing multi-seat ITs stay green on the refactored
helper. Client re-render needs live 3-party verification on staging.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:42:22 -04:00

1159 lines
51 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")
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-<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.
# 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-<owner_id>.
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_<owner_id>`) 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": "<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)
_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,
})