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>
1042 lines
45 KiB
Python
1042 lines
45 KiB
Python
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,
|
||
})
|