Bundled fix for the PAID-DRAW-loops-to-GATE-VIEW bug surfaced 2026-05-20 in live testing: previously the view reset `created_at = now()` + cleared the hand, but the row's continued existence meant `quota_spent=True` on the next render → landing rendered GATE VIEW → user clicked it → back to gatekeeper → loop. Now PAID DRAW does `active_draw.delete()` after debiting the token + then redirects to `/gameboard/my-sea/?phase=picker`. The my_sea view honors `?phase=picker` (only when no active_draw exists — can't bypass post-DEL GATE VIEW) by forcing `show_picker=True` so the user lands in the picker ready to draw. First card draw creates a fresh row w. fresh `created_at`, starting the new 24h quota cycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
573 lines
24 KiB
Python
573 lines
24 KiB
Python
import json
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.http import HttpResponse, JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils import timezone
|
|
from django.views.decorators.http import require_POST
|
|
|
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
|
from .models import (
|
|
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for,
|
|
_select_my_sea_token, debit_my_sea_token,
|
|
)
|
|
|
|
|
|
def _annotate_deck_in_use(decks, user):
|
|
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
|
|
active = {
|
|
ts.deck_variant_id: ts.room.name
|
|
for ts in TableSeat.objects.filter(
|
|
gamer=user, deck_variant__isnull=False,
|
|
).select_related("room")
|
|
}
|
|
for deck in decks:
|
|
deck.in_use_room_name = active.get(deck.pk)
|
|
return decks
|
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
|
from apps.epic.utils import annotate_latest_event, rooms_for_user
|
|
from apps.lyric.models import Token
|
|
|
|
|
|
GAMEBOARD_APPLET_ORDER = [
|
|
"new-game",
|
|
"my-games",
|
|
"game-kit",
|
|
]
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def gameboard(request):
|
|
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
|
coin = request.user.tokens.filter(token_type=Token.COIN).first()
|
|
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
|
|
free_tokens = list(request.user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
return render(
|
|
request, "apps/gameboard/gameboard.html", {
|
|
"pass_token": pass_token,
|
|
"coin": coin,
|
|
"carte": carte,
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
|
"free_tokens": free_tokens,
|
|
"free_count": len(free_tokens),
|
|
"applets": applet_context(request.user, "gameboard"),
|
|
"page_class": "page-gameboard",
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
|
}
|
|
)
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_game_applets(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "gameboard", checked)
|
|
if request.headers.get("HX-Request"):
|
|
free_tokens = list(request.user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
return render(request, "apps/gameboard/_partials/_applets.html", {
|
|
"applets": applet_context(request.user, "gameboard"),
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
|
"free_tokens": free_tokens,
|
|
"free_count": len(free_tokens),
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
|
})
|
|
return redirect("gameboard")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def equip_trinket(request, token_id):
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
if request.method == "POST":
|
|
request.user.equipped_trinket = token
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
return HttpResponse(status=204)
|
|
return render(
|
|
request,
|
|
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
|
{"token": token},
|
|
)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def equip_deck(request, deck_id):
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
if request.method == "POST":
|
|
request.user.equipped_deck = deck
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def unequip_trinket(request, token_id):
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
if request.method == "POST":
|
|
if request.user.equipped_trinket_id == token.pk:
|
|
request.user.equipped_trinket = None
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def unequip_deck(request, deck_id):
|
|
get_object_or_404(DeckVariant, pk=deck_id)
|
|
if request.method == "POST":
|
|
if request.user.equipped_deck_id == deck_id:
|
|
request.user.equipped_deck = None
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
return HttpResponse(status=204)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
def _game_kit_context(user):
|
|
from apps.lyric.models import PRONOUN_CHOICES
|
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
|
free_tokens = list(user.tokens.filter(
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
).order_by("expires_at"))
|
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
|
pronoun_options = [
|
|
{"key": k, "label": label, "active": (k == user.pronouns)}
|
|
for (k, label) in PRONOUN_CHOICES
|
|
]
|
|
return {
|
|
"coin": coin,
|
|
"pass_token": pass_token,
|
|
"carte": carte,
|
|
"free_tokens": free_tokens,
|
|
"tithe_tokens": tithe_tokens,
|
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
|
"applets": applet_context(user, "game-kit"),
|
|
"pronoun_options": pronoun_options,
|
|
"current_pronouns": user.pronouns,
|
|
}
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def game_kit(request):
|
|
return render(request, "apps/gameboard/game_kit.html", {
|
|
**_game_kit_context(request.user),
|
|
"page_class": "page-gameboard",
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def toggle_game_kit_sections(request):
|
|
checked = request.POST.getlist("applets")
|
|
apply_applet_toggle(request.user, "game-kit", checked)
|
|
if request.headers.get("HX-Request"):
|
|
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
|
_game_kit_context(request.user))
|
|
return redirect("game_kit")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def my_sea(request):
|
|
"""Shell view for the My Sea standalone page.
|
|
|
|
Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
|
|
hand storage AND 24h quota tracker. The row is created on first
|
|
card draw + survives DEL (which only wipes the hand). View states:
|
|
|
|
1. No sig → Look!-formatted sign-gate (Sprint 4b).
|
|
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
|
|
w. saved hand state. The DEL btn is server-rendered `.btn-
|
|
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
|
|
completion. The draw's sig snapshot is rendered (NOT user.
|
|
significator) so a cleared sig elsewhere doesn't invalidate the
|
|
saved draw.
|
|
3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
|
|
free-draw quota is spent; user must use tokens via the upcoming
|
|
Sprint 6 gatekeeper). Brief banner still surfaces the next-
|
|
free-draw timestamp.
|
|
4. No active draw + sig set → landing w. FREE DRAW (the daily quota
|
|
is available).
|
|
4a. + no equipped deck → also show backup-deck Brief banner.
|
|
"""
|
|
active_draw = active_draw_for(request.user)
|
|
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
|
user_has_sig = sig_card is not None
|
|
no_equipped_deck = request.user.equipped_deck_id is None
|
|
|
|
if active_draw is not None:
|
|
default_spread = active_draw.spread
|
|
saved_hand = active_draw.hand
|
|
next_free_draw_at = active_draw.next_free_draw_at
|
|
hand_complete = active_draw.is_hand_complete
|
|
hand_empty = active_draw.is_hand_empty
|
|
else:
|
|
default_spread = "situation-action-outcome"
|
|
saved_hand = []
|
|
next_free_draw_at = None
|
|
hand_complete = False
|
|
hand_empty = True
|
|
# Picker is the active phase iff the user has a non-empty hand in
|
|
# progress (or completed). Empty-hand active draws (post-DEL) fall
|
|
# back to the landing — but render GATE VIEW instead of FREE DRAW
|
|
# (the daily quota's spent already; landing's primary nav routes to
|
|
# the upcoming gatekeeper). New users + post-24h users land on the
|
|
# standard FREE DRAW landing.
|
|
#
|
|
# `?phase=picker` query param (set by PAID DRAW's redirect) forces
|
|
# the picker even when active_draw is None — the user just paid a
|
|
# token, so drop them straight into the picker rather than making
|
|
# them click FREE DRAW first. Only honored when active_draw is None
|
|
# (post-PAID-DRAW state); existing rows route through the normal
|
|
# logic above so the param can't accidentally bypass a GATE VIEW
|
|
# or empty-hand state.
|
|
phase_param = request.GET.get("phase") == "picker"
|
|
show_picker = (active_draw is not None and not hand_empty) or (
|
|
active_draw is None and phase_param
|
|
)
|
|
quota_spent = active_draw is not None # any active row = quota committed
|
|
# Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence.
|
|
# `deposit_reserved` toggles the landing primary from GATE VIEW to
|
|
# PAID DRAW (one-click commit of the already-deposited token).
|
|
# `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads
|
|
# don't lose the JS-only animation state.
|
|
deposit_reserved = (
|
|
active_draw is not None and active_draw.deposit_token_id is not None
|
|
)
|
|
hand_non_empty = active_draw is not None and bool(active_draw.hand)
|
|
|
|
# Per-position lookup for the template — keyed by the position slug
|
|
# ("lay", "cover", ...) so each `.sea-pos-<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 "",
|
|
}
|
|
|
|
return render(request, "apps/gameboard/my_sea.html", {
|
|
"user_has_sig": user_has_sig,
|
|
"no_equipped_deck": no_equipped_deck,
|
|
"show_backup_intro_banner": (
|
|
user_has_sig and no_equipped_deck and active_draw is None
|
|
),
|
|
"significator": sig_card,
|
|
"significator_reversed": sig_reversed,
|
|
"default_spread": default_spread,
|
|
"reversals_pct": 25,
|
|
"sea_deck_data": (
|
|
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
|
if user_has_sig else {"levity": [], "gravity": []}
|
|
),
|
|
# Iter 4b / 4c
|
|
"active_draw": active_draw,
|
|
"saved_hand": saved_hand,
|
|
"saved_by_position": saved_by_position,
|
|
"next_free_draw_at": next_free_draw_at,
|
|
"hand_complete": hand_complete,
|
|
"show_picker": show_picker,
|
|
"quota_spent": quota_spent,
|
|
"deposit_reserved": deposit_reserved,
|
|
"hand_non_empty": hand_non_empty,
|
|
"page_class": "page-gameboard page-my-sea",
|
|
})
|
|
|
|
|
|
def _resolve_sig(user, active_draw):
|
|
"""When an active draw exists, render its sig snapshot — even if
|
|
user.significator has since been cleared (per user spec, preserve the
|
|
old sig on the saved draw). Otherwise use user.significator."""
|
|
if active_draw is not None:
|
|
from apps.epic.models import TarotCard
|
|
sig = TarotCard.objects.filter(id=active_draw.significator_id).first()
|
|
return sig, active_draw.significator_reversed
|
|
return user.significator, user.significator_reversed
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_lock(request):
|
|
"""Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
|
|
fires on every card placement (manual FLIP or AUTO DRAW completion)
|
|
rather than only on a discrete LOCK HAND action.
|
|
|
|
Body: JSON `{"spread": "<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 attempts get 409 — the spread is committed at
|
|
# first-card moment.
|
|
if existing.spread != spread:
|
|
return JsonResponse({"error": "spread_mismatch"}, status=409)
|
|
existing.hand = hand
|
|
existing.save(update_fields=["hand"])
|
|
return JsonResponse({
|
|
"ok": True,
|
|
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
|
|
"hand_complete": existing.is_hand_complete,
|
|
})
|
|
|
|
# First card draw → quota commit. Create the row.
|
|
sig_id = request.user.significator_id
|
|
if sig_id is None:
|
|
return JsonResponse({"error": "no_significator"}, status=400)
|
|
|
|
draw = MySeaDraw.objects.create(
|
|
user=request.user,
|
|
spread=spread,
|
|
hand=hand,
|
|
significator_id=sig_id,
|
|
significator_reversed=request.user.significator_reversed,
|
|
)
|
|
return JsonResponse({
|
|
"ok": True,
|
|
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
|
"hand_complete": draw.is_hand_complete,
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_delete(request):
|
|
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
|
|
so the 24h quota window keeps running. Per user spec (2026-05-20):
|
|
DEL doesn't refund the daily free-draw; the row stays as a quota
|
|
tracker until 24h elapse, after which `active_draw_for`'s lazy
|
|
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
|
|
|
|
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
|
|
draw = active_draw_for(request.user)
|
|
if draw is not None:
|
|
draw.hand = []
|
|
draw.save(update_fields=["hand"])
|
|
return HttpResponse(status=204)
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def my_sea_gate(request):
|
|
"""Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's
|
|
`_gatekeeper.html` structure (coin-slot rails + refund affordance)
|
|
adapted for 1-user, 1-token-per-redraw semantics. The user spends a
|
|
token (PASS/COIN/FREE/TITHE — CARTE excluded) to acquire a fresh
|
|
24h quota cycle after their daily free draw is spent.
|
|
|
|
Branches on `MySeaDraw.deposit_token_id`:
|
|
- None (no deposit yet) → INSERT TOKEN TO PLAY rails are active.
|
|
- non-None (deposit reserved) → refund affordance + PAID DRAW btn.
|
|
"""
|
|
active_draw = active_draw_for(request.user)
|
|
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
|
deposit_reserved = (
|
|
active_draw is not None and active_draw.deposit_token_id is not None
|
|
)
|
|
hand_non_empty = active_draw is not None and bool(active_draw.hand)
|
|
return render(request, "apps/gameboard/my_sea_gate.html", {
|
|
"user_has_sig": sig_card is not None,
|
|
"significator": sig_card,
|
|
"significator_reversed": sig_reversed,
|
|
"active_draw": active_draw,
|
|
"deposit_reserved": deposit_reserved,
|
|
"hand_non_empty": hand_non_empty,
|
|
"page_class": "page-gameboard page-my-sea page-my-sea-gate",
|
|
})
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_insert_token(request):
|
|
"""Reserve the user's next-priority token on their MySeaDraw row.
|
|
Idempotent w.r.t. an already-reserved deposit — re-posting is a no-op
|
|
rather than double-debit. Creates the row if none exists (so a fresh
|
|
user can hit the gatekeeper without first using their free draw)."""
|
|
active_draw = active_draw_for(request.user)
|
|
if active_draw is None:
|
|
# No active row yet — create a quota tracker row w. empty hand
|
|
# so the deposit has something to attach to. This also commits
|
|
# the user's free-draw quota for the day (since `active_draw_
|
|
# for` will now return this row).
|
|
sig_id = request.user.significator_id
|
|
if sig_id is None:
|
|
return redirect("my_sea_gate")
|
|
active_draw = MySeaDraw.objects.create(
|
|
user=request.user,
|
|
spread="situation-action-outcome",
|
|
hand=[],
|
|
significator_id=sig_id,
|
|
significator_reversed=request.user.significator_reversed,
|
|
)
|
|
if active_draw.deposit_token_id is not None:
|
|
return redirect("my_sea_gate")
|
|
token = _select_my_sea_token(request.user)
|
|
if token is None:
|
|
return redirect("my_sea_gate")
|
|
active_draw.deposit_token_id = token.pk
|
|
active_draw.deposit_reserved_at = timezone.now()
|
|
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
|
return redirect("my_sea_gate")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_refund_token(request):
|
|
"""Clear the user's deposit reservation. Token wasn't actually
|
|
debited at INSERT (refund-aware design), so this is purely a row
|
|
update — no side effects on the user's inventory."""
|
|
active_draw = active_draw_for(request.user)
|
|
if active_draw is not None and active_draw.deposit_token_id is not None:
|
|
active_draw.deposit_token_id = None
|
|
active_draw.deposit_reserved_at = None
|
|
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
|
return redirect("my_sea_gate")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_paid_draw(request):
|
|
"""Commit the deposited token + drop the active_draw row so the
|
|
user returns to a fresh "able-to-draw-now" state. Without the row,
|
|
`quota_spent` resolves to False on the next my-sea render → the
|
|
user can draw cards immediately (the token they just spent earns
|
|
them this 24h cycle's worth of draws).
|
|
|
|
The token is debited via `debit_my_sea_token` (FREE/TITHE consumed;
|
|
COIN 24h cooldown + unequipped; PASS no-op). The row is then
|
|
deleted (rather than just reset) — user-spec 2026-05-20: keeping
|
|
the row but resetting created_at left `quota_spent=True` on the
|
|
next view, looping the user back to GATE VIEW. Delete sidesteps
|
|
that entirely.
|
|
|
|
Redirects to /gameboard/my-sea/?phase=picker so the user lands
|
|
directly in the picker (skipping the FREE DRAW landing click).
|
|
"""
|
|
from django.urls import reverse
|
|
from apps.lyric.models import Token
|
|
active_draw = active_draw_for(request.user)
|
|
if active_draw is None or active_draw.deposit_token_id is None:
|
|
return redirect("my_sea")
|
|
token = Token.objects.filter(
|
|
pk=active_draw.deposit_token_id, user=request.user,
|
|
).first()
|
|
if token is None:
|
|
# Token vanished between reserve + commit (unlikely w. solo
|
|
# flow but defensive). Clear deposit + bounce to my-sea.
|
|
active_draw.deposit_token_id = None
|
|
active_draw.deposit_reserved_at = None
|
|
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
|
return redirect("my_sea")
|
|
debit_my_sea_token(request.user, token)
|
|
active_draw.delete()
|
|
return redirect(reverse("my_sea") + "?phase=picker")
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def my_sea_invite(request):
|
|
"""Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper.
|
|
Async multi-user invite is deferred to a later sprint; this endpoint
|
|
just returns a Brief banner announcing "coming soon" so the bud-btn
|
|
panel has a non-broken success path."""
|
|
from django.urls import reverse
|
|
return JsonResponse({
|
|
"brief": {
|
|
"title": "Multiplayer my-sea",
|
|
"line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.",
|
|
"post_url": reverse("gameboard"),
|
|
"created_at": "",
|
|
"kind": "NUDGE",
|
|
},
|
|
"recipient_display": (request.POST.get("recipient") or "").strip(),
|
|
})
|
|
|
|
|
|
def _my_sea_deck_data(user, exclude_id=None):
|
|
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
|
picker's card-draw mechanic. Card payload shape is whatever
|
|
`apps.epic.utils.card_dict` defines (single source of truth shared
|
|
w. the gameroom `sea_deck` endpoint).
|
|
|
|
Differences from the room version:
|
|
- No `room` context — exclude only the sig card (no other seated
|
|
gamers to worry about). `exclude_id` defaults to `user.significator_id`
|
|
but callers can pass a draw's snapshotted sig id when the saved-
|
|
draw branch is rendering.
|
|
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
|
|
fall back to Earthman (mirrors `personal_sig_cards`).
|
|
"""
|
|
import random
|
|
from apps.epic.models import DeckVariant, TarotCard
|
|
from apps.epic.utils import card_dict, stack_reversal_probability
|
|
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
|
|
if not deck:
|
|
return {"levity": [], "gravity": []}
|
|
if exclude_id is None:
|
|
exclude_id = user.significator_id
|
|
available = list(TarotCard.objects.filter(deck_variant=deck))
|
|
if exclude_id:
|
|
available = [c for c in available if c.id != exclude_id]
|
|
random.shuffle(available)
|
|
mid = len(available) // 2
|
|
reversal_prob = stack_reversal_probability(user)
|
|
return {
|
|
"levity": [card_dict(c, reversal_prob) for c in available[:mid]],
|
|
"gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
|
|
}
|
|
|
|
|
|
@login_required(login_url="/")
|
|
def tarot_fan(request, deck_id):
|
|
from apps.epic.models import TarotCard
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
|
return HttpResponse(status=403)
|
|
_suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3,
|
|
"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3}
|
|
cards = sorted(
|
|
TarotCard.objects.filter(deck_variant=deck),
|
|
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
|
)
|
|
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
|
|
"deck": deck,
|
|
"cards": cards,
|
|
})
|