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>
909 lines
54 KiB
HTML
909 lines
54 KiB
HTML
{% extends "core/base.html" %}
|
|
{% load static %}
|
|
|
|
{% block title_text %}Game Sea{% endblock title_text %}
|
|
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
|
|
|
{% block content %}
|
|
<div class="my-sea-page"
|
|
data-phase="{% if show_picker %}picker{% else %}landing{% endif %}"
|
|
data-polarity="{% if significator_reversed %}gravity{% else %}levity{% endif %}">
|
|
{% if not user_has_sig %}
|
|
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
|
{# significator — render a Look!-formatted Brief-style line w. #}
|
|
{# FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) until the #}
|
|
{# user picks a sign. Inline (not portaled like .note-banner) #}
|
|
{# because the gate IS the page content, not a transient nudge. #}
|
|
<div class="my-sea-sign-gate">
|
|
<p class="my-sea-sign-gate__line">
|
|
Look!—pick your sign before drawing the Sea.
|
|
</p>
|
|
<div class="my-sea-sign-gate__actions">
|
|
<a class="btn btn-cancel my-sea-sign-gate__back"
|
|
href="{% url 'gameboard' %}">NVM</a>
|
|
<a class="btn btn-info my-sea-sign-gate__fyi"
|
|
href="{% url 'billboard:my_sign' %}">FYI</a>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
{% if not show_picker %}
|
|
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
|
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
|
|
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
|
|
{# friend-invite feature per the My Sea roadmap architectural #}
|
|
{# anchor "Six chairs retained even in solo". #}
|
|
{# #}
|
|
{# Iter 6b — landing primary-btn state machine, 3-way: #}
|
|
{# • FREE DRAW (`#id_draw_sea_btn`) when no active quota row #}
|
|
{# (fresh user OR >24h since last draw); #}
|
|
{# • PAID DRAW (`#id_my_sea_paid_draw_btn`) when the user has #}
|
|
{# deposited a token via the gatekeeper but hasn't yet #}
|
|
{# committed it; one-click POST commits + redirects to #}
|
|
{# fresh-quota /gameboard/my-sea/. #}
|
|
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
|
|
{# quota row exists, the hand is empty, AND no deposit yet #}
|
|
{# (post-DEL state). Routes to the Sprint-6 gatekeeper. #}
|
|
{# Seat 1 (`data-slot="1"`) carries `.seated` + `.fa-circle- #}
|
|
{# check` when the user's hand is non-empty (server-rendered #}
|
|
{# so reloads preserve the state). Otherwise `.fa-ban`. Other #}
|
|
{# 5 seats are placeholders for the future friend-invite #}
|
|
{# feature — always banned in solo my-sea. #}
|
|
<div class="my-sea-landing">
|
|
<div class="room-shell">
|
|
<div id="id_game_table" class="room-table">
|
|
<div class="room-table-scene">
|
|
<div class="table-hex-border">
|
|
<div class="table-hex">
|
|
<div class="table-center">
|
|
{% if deposit_reserved %}
|
|
<form method="POST" action="{% url 'my_sea_paid_draw' %}" style="display:contents">
|
|
{% csrf_token %}
|
|
<button type="submit"
|
|
id="id_my_sea_paid_draw_btn"
|
|
class="btn btn-primary">PAID<br>DRAW</button>
|
|
</form>
|
|
{% elif quota_spent %}
|
|
<button id="id_my_sea_gate_view_btn"
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-gate-url="{% url 'my_sea_gate' %}">GATE<br>VIEW</button>
|
|
{% else %}
|
|
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% for n in "123456" %}
|
|
{# Chair-position labels (1C-6C). No roles in #}
|
|
{# my-sea (this is the solo draw flow); using #}
|
|
{# `.seat-position-label` instead of the room's #}
|
|
{# `.seat-role-label` to keep the no-role #}
|
|
{# semantics clean. `.position-status-icon` + #}
|
|
{# `.fa-ban` are unchanged — already role- #}
|
|
{# agnostic in _room.scss. #}
|
|
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}">
|
|
<i class="fa-solid fa-chair"></i>
|
|
<span class="seat-position-label">{{ n }}C</span>
|
|
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Picker phase — per-spread flexible layout. Sig pins .sea- #}
|
|
{# pos-core; the 6 surrounding positions all render in DOM #}
|
|
{# so the SPREAD dropdown can swap `.my-sea-cross[data- #}
|
|
{# spread]` between the 4 three-card variants (each w. its #}
|
|
{# own 3-position subset + draw order) + the 2 six-card #}
|
|
{# Celtic Cross variants (all 6 surrounding positions). #}
|
|
{# Each empty slot carries a `.sea-pos-label` caption (re- #}
|
|
{# appropriated from the GRAVITY/LEVITY .sea-stack-name look) #}
|
|
{# that JS updates per spread. #}
|
|
{# #}
|
|
{# `id="id_sea_overlay"` aliases the picker to what SeaDeal #}
|
|
{# binds to (the gameroom uses the same ID on a different #}
|
|
{# page — no DOM collision since my-sea + gameroom never co- #}
|
|
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
|
|
{# openStage(), which fills the slot AND opens the portaled #}
|
|
{# stage modal w. SPIN / FYI controls. #}
|
|
<div class="my-sea-picker{% if hand_complete %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not show_picker %} style="display:none"{% endif %}>
|
|
<div class="sea-cards-col">
|
|
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
|
|
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
|
|
{# `slot.innerHTML = …` (which writes the drawn card's corner- #}
|
|
{# rank + suit-icon) doesn't clobber it. Labels persist as #}
|
|
{# adjacent siblings + are positioned via absolute SCSS to #}
|
|
{# touch the slot's nearest edge. #}
|
|
<div class="sea-crucifix-cell sea-pos-crown">
|
|
<span class="sea-pos-label" data-position="crown">Outcome</span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="crown" saved=saved_by_position.crown crossing=False %}
|
|
</div>
|
|
<div class="sea-crucifix-cell sea-pos-leave">
|
|
<span class="sea-pos-label" data-position="leave"></span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="leave" saved=saved_by_position.leave crossing=False %}
|
|
</div>
|
|
<div class="sea-crucifix-cell sea-pos-core">
|
|
<div class="sig-stage-card sea-sig-card"
|
|
data-card-id="{{ significator.id }}">
|
|
<span class="fan-corner-rank">{{ significator.corner_rank }}</span>
|
|
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
|
|
</div>
|
|
<div class="sea-pos-cover">
|
|
<span class="sea-pos-label" data-position="cover">Action</span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cover" saved=saved_by_position.cover crossing=False %}
|
|
</div>
|
|
<div class="sea-pos-cross">
|
|
<span class="sea-pos-label" data-position="cross"></span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cross" saved=saved_by_position.cross crossing=True %}
|
|
</div>
|
|
</div>
|
|
<div class="sea-crucifix-cell sea-pos-loom">
|
|
<span class="sea-pos-label" data-position="loom"></span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="loom" saved=saved_by_position.loom crossing=False %}
|
|
</div>
|
|
<div class="sea-crucifix-cell sea-pos-lay">
|
|
<span class="sea-pos-label" data-position="lay">Situation</span>
|
|
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="lay" saved=saved_by_position.lay crossing=False %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Form col — SPREAD combobox + DECKS swatches + LOCK #}
|
|
{# HAND / DEL. DRY w. gameroom `_sea_overlay.html`'s #}
|
|
{# `.sea-form-col` shape; my-sea-specific differences: #}
|
|
{# (a) 6 spread options under 2 section dividers, #}
|
|
{# (b) default = situation-action-outcome (3-card), #}
|
|
{# (c) no `.sea-modal-header` (the gateway IS the page). #}
|
|
<div class="sea-form-col my-sea-form-col">
|
|
<div class="sea-form-main">
|
|
<div class="sea-field">
|
|
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
|
|
<p class="sea-reversal-hint">{{ reversals_pct|default:25 }}% reversals</p>
|
|
{# autocomplete="off" opts the hidden input out of #}
|
|
{# Firefox's form-history autofill, which otherwise #}
|
|
{# restores the LAST value on soft reload (F5). #}
|
|
{# Without this, combobox.js's `select(i)` short- #}
|
|
{# circuits its change-event dispatch when the #}
|
|
{# user re-picks the value Firefox already restored #}
|
|
{# → my-sea's sync() never fires → data-spread on #}
|
|
{# .my-sea-cross stays stuck on SAO default. #}
|
|
<input type="hidden" id="id_sea_spread" name="spread"
|
|
value="{{ default_spread }}" autocomplete="off">
|
|
<div class="sea-select"
|
|
data-combobox
|
|
data-combobox-target="id_sea_spread"
|
|
role="combobox"
|
|
aria-expanded="false"
|
|
aria-haspopup="listbox"
|
|
aria-labelledby="id_sea_spread_label"
|
|
tabindex="0">
|
|
<span class="sea-select-current">{% if default_spread == 'past-present-future' %}Past, Present, Future{% elif default_spread == 'mind-body-spirit' %}Mind, Body, Spirit{% elif default_spread == 'desire-obstacle-solution' %}Desire, Obstacle, Solution{% elif default_spread == 'waite-smith' %}Celtic Cross, Waite-Smith{% elif default_spread == 'escape-velocity' %}Celtic Cross, Escape Velocity{% else %}Situation, Action, Outcome{% endif %}</span>
|
|
<span class="sea-select-arrow" aria-hidden="true">▾</span>
|
|
<ul class="sea-select-list" role="listbox">
|
|
<li role="presentation" class="sea-select-divider">3-card spreads</li>
|
|
<li role="option" data-value="past-present-future" aria-selected="{% if default_spread == 'past-present-future' %}true{% else %}false{% endif %}">Past, Present, Future</li>
|
|
<li role="option" data-value="situation-action-outcome" aria-selected="{% if default_spread == 'situation-action-outcome' %}true{% else %}false{% endif %}">Situation, Action, Outcome</li>
|
|
<li role="option" data-value="mind-body-spirit" aria-selected="{% if default_spread == 'mind-body-spirit' %}true{% else %}false{% endif %}">Mind, Body, Spirit</li>
|
|
<li role="option" data-value="desire-obstacle-solution" aria-selected="{% if default_spread == 'desire-obstacle-solution' %}true{% else %}false{% endif %}">Desire, Obstacle, Solution</li>
|
|
<li role="presentation" class="sea-select-divider">6-card spreads</li>
|
|
<li role="option" data-value="waite-smith" aria-selected="{% if default_spread == 'waite-smith' %}true{% else %}false{% endif %}">Celtic Cross, Waite-Smith</li>
|
|
<li role="option" data-value="escape-velocity" aria-selected="{% if default_spread == 'escape-velocity' %}true{% else %}false{% endif %}">Celtic Cross, Escape Velocity</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sea-stacks">
|
|
<span class="sea-stacks-label">DECKS</span>
|
|
<div class="sea-deck-stack sea-deck-stack--gravity">
|
|
<div class="sea-stack-face">
|
|
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
|
</div>
|
|
<span class="sea-stack-name">Gravity</span>
|
|
</div>
|
|
<div class="sea-deck-stack sea-deck-stack--levity">
|
|
<div class="sea-stack-face">
|
|
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
|
</div>
|
|
<span class="sea-stack-name">Levity</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sea-form-actions">
|
|
{# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) #}
|
|
{# / GATE VIEW (hand complete). Same button slot; JS #}
|
|
{# transitions label + behavior + `data-state` when #}
|
|
{# the final card lands. AUTO DRAW opens a guard portal #}
|
|
{# ("Auto deal cards?") + POSTs the remaining hand in #}
|
|
{# one shot so a navigate-away mid-animation still #}
|
|
{# persists. GATE VIEW navigates to the Sprint-6 gate- #}
|
|
{# keeper (currently a 404 stub). #}
|
|
<button type="button"
|
|
id="id_sea_action_btn"
|
|
class="btn btn-primary"
|
|
data-state="{% if hand_complete %}gate-view{% else %}auto-draw{% endif %}"
|
|
data-gate-url="{% url 'my_sea_gate' %}">{% if hand_complete %}GATE<br>VIEW{% else %}AUTO<br>DRAW{% endif %}</button>
|
|
{# DEL starts `.btn-disabled` until the hand is #}
|
|
{# complete — per Sprint-5-iter-4c spec, the 1/day #}
|
|
{# quota is committed at first-card-draw + can't be #}
|
|
{# refunded by an early DEL. #}
|
|
<button type="button"
|
|
id="id_sea_del"
|
|
class="btn btn-danger{% if not hand_complete %} btn-disabled{% endif %}">
|
|
DEL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{# Sea stage — portaled modal that opens on FLIP click via #}
|
|
{# SeaDeal.openStage. `position:fixed; inset:0` covers the #}
|
|
{# viewport; click backdrop to dismiss + reveal the slot #}
|
|
{# thumbnail. #}
|
|
{% include "apps/gameboard/_partials/_sea_stage.html" %}
|
|
</div>
|
|
|
|
{# Iter 4b — DEL guard reuses the shared `#id_guard_portal` #}
|
|
{# from base.html (the same one the room's gear-menu DEL btn #}
|
|
{# uses). Gaussian-glass tooltip positioned above the DEL btn,#}
|
|
{# no backdrop. The picker IIFE below invokes it via #}
|
|
{# `window.showGuard(delBtn, "Are you sure?", confirmFn, null,#}
|
|
{# {yesLabel: "DEL"})` when DEL is clicked post-lock. #}
|
|
{# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #}
|
|
{# sig excluded) embedded as JSON; JS reads on init and #}
|
|
{# pops from the relevant pile on each deposit. #}
|
|
{{ sea_deck_data|json_script:"id_my_sea_deck" }}
|
|
{# StageCard + SeaDeal — both bind to `#id_sea_overlay` (the #}
|
|
{# my-sea-picker) + `#id_sea_stage` (the stage partial) on #}
|
|
{# DOMContentLoaded; openStage() runs on FLIP click below. #}
|
|
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
|
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
|
<script src="{% static 'apps/epic/combobox.js' %}"></script>
|
|
<script>
|
|
(function () {
|
|
// Per-spread draw order + position labels — locked in spec
|
|
// (user 2026-05-19). Each three-card spread uses a DIFFERENT
|
|
// 3-position subset of the 6 surrounding positions, in a
|
|
// specific order. The Celtic Cross variants share position
|
|
// labels (Crown/Beneath/Cover/Cross/Before/Behind — gameroom
|
|
// vocabulary) but differ in draw order.
|
|
var DRAW_ORDER = {
|
|
'past-present-future': ['leave', 'cover', 'loom'],
|
|
'situation-action-outcome': ['lay', 'cover', 'crown'],
|
|
'mind-body-spirit': ['crown', 'lay', 'loom'],
|
|
'desire-obstacle-solution': ['loom', 'cross', 'crown'],
|
|
'waite-smith': ['cover', 'cross', 'crown', 'lay', 'loom', 'leave'],
|
|
'escape-velocity': ['cover', 'cross', 'lay', 'leave', 'crown', 'loom'],
|
|
};
|
|
var POSITION_LABELS = {
|
|
'past-present-future': { leave: 'Past', cover: 'Present', loom: 'Future' },
|
|
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
|
|
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
|
|
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
|
|
'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
|
|
// Escape Velocity remaps the diagonal positions per the
|
|
// user-locked spec (2026-05-19): Beneath→Lay, Before→
|
|
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
|
|
// names.
|
|
'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' },
|
|
};
|
|
var hidden = document.getElementById('id_sea_spread');
|
|
var cross = document.querySelector('.my-sea-cross');
|
|
var picker = document.querySelector('.my-sea-picker');
|
|
var actionBtn = document.getElementById('id_sea_action_btn');
|
|
var delBtn = document.getElementById('id_sea_del');
|
|
var deckEl = document.getElementById('id_my_sea_deck');
|
|
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
|
|
if (!hidden || !cross || !picker) return;
|
|
|
|
// ── Deck state ──────────────────────────────────────────
|
|
// `_deckData` is the immutable initial payload from the
|
|
// server; `_levityPile` + `_gravityPile` are working
|
|
// copies that pop one card per deposit. DEL re-clones
|
|
// from `_deckData` rather than re-fetching.
|
|
var _deckData = { levity: [], gravity: [] };
|
|
try { _deckData = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
|
var _levityPile = (_deckData.levity || []).slice();
|
|
var _gravityPile = (_deckData.gravity || []).slice();
|
|
var _filled = 0;
|
|
var _activeStack = null;
|
|
var _locked = false;
|
|
|
|
function _currentOrder() {
|
|
return DRAW_ORDER[hidden.value] || [];
|
|
}
|
|
|
|
function _hideOk() {
|
|
if (_activeStack) {
|
|
var ok = _activeStack.querySelector('.sea-stack-ok');
|
|
if (ok) ok.style.display = 'none';
|
|
_activeStack.classList.remove('sea-deck-stack--active');
|
|
_activeStack = null;
|
|
}
|
|
}
|
|
function _showOk(stack) {
|
|
_hideOk();
|
|
_activeStack = stack;
|
|
stack.classList.add('sea-deck-stack--active');
|
|
var ok = stack.querySelector('.sea-stack-ok');
|
|
if (ok) ok.style.display = '';
|
|
}
|
|
|
|
function _fillSlot(positionName, card, isLevity) {
|
|
// Lifted from gameroom sea.js's `_fillSlot`: strip
|
|
// .--empty + the position label, layer .--filled +
|
|
// polarity classes, set corner-rank + suit-icon.
|
|
var cell = cross.querySelector('.sea-pos-' + positionName);
|
|
if (!cell) return;
|
|
var slot = cell.querySelector('.sea-card-slot');
|
|
if (!slot) return;
|
|
slot.classList.remove('sea-card-slot--empty');
|
|
slot.classList.add('sea-card-slot--filled');
|
|
slot.classList.add(isLevity ? 'sea-card-slot--levity' : 'sea-card-slot--gravity');
|
|
if (card.reversed) slot.classList.add('sea-card-slot--reversed');
|
|
slot.dataset.cardId = String(card.id);
|
|
slot.dataset.posKey = positionName;
|
|
slot.innerHTML =
|
|
'<span class="fan-corner-rank">' + (card.corner_rank || '') + '</span>' +
|
|
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
|
|
}
|
|
|
|
function _emptySlot(cell) {
|
|
// DEL restores each filled slot to its initial empty
|
|
// state. `.sea-pos-label` is now a SIBLING of the slot
|
|
// (outside it) so SeaDeal's innerHTML clobber on draw
|
|
// doesn't touch it — we don't need to re-render the
|
|
// label here, just wipe slot contents + classes.
|
|
var slot = cell.querySelector('.sea-card-slot');
|
|
if (!slot) return;
|
|
slot.className = slot.className
|
|
.split(' ')
|
|
.filter(function (c) {
|
|
return !/^sea-card-slot--(filled|visible|focused|levity|gravity|reversed|rank-long)$/.test(c);
|
|
})
|
|
.join(' ');
|
|
slot.classList.add('sea-card-slot--empty');
|
|
delete slot.dataset.cardId;
|
|
delete slot.dataset.posKey;
|
|
slot.innerHTML = '';
|
|
}
|
|
|
|
function _lockSpread() {
|
|
// Lock the SPREAD combobox after the first deposit —
|
|
// switching spread mid-draw would scramble the in-
|
|
// progress hand. Unlocks on DEL.
|
|
if (seaSelect) {
|
|
seaSelect.classList.add('sea-select--locked');
|
|
seaSelect.setAttribute('aria-disabled', 'true');
|
|
}
|
|
}
|
|
function _unlockSpread() {
|
|
if (seaSelect) {
|
|
seaSelect.classList.remove('sea-select--locked');
|
|
seaSelect.removeAttribute('aria-disabled');
|
|
}
|
|
}
|
|
|
|
function _reshuffleDeck() {
|
|
// Fisher-Yates re-shuffle on DEL — re-distributes cards
|
|
// across both polarity halves + re-rolls the 25% reversal
|
|
// axis per card. Page-load shuffle already came from the
|
|
// server (`_my_sea_deck_data`); subsequent shuffles run
|
|
// client-side so DEL → fresh-hand doesn't require a
|
|
// network round-trip.
|
|
var all = (_deckData.levity || []).concat(_deckData.gravity || []);
|
|
for (var i = all.length - 1; i > 0; i--) {
|
|
var j = Math.floor(Math.random() * (i + 1));
|
|
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
|
|
}
|
|
// Clone each card (don't mutate the immutable server payload
|
|
// — DEL can fire many times; we don't want successive shuffles
|
|
// to fold previous reversal flips into the next round).
|
|
all = all.map(function (c) {
|
|
var clone = {};
|
|
for (var k in c) if (Object.prototype.hasOwnProperty.call(c, k)) clone[k] = c[k];
|
|
clone.reversed = Math.random() < 0.25;
|
|
return clone;
|
|
});
|
|
var mid = Math.floor(all.length / 2);
|
|
_levityPile = all.slice(0, mid);
|
|
_gravityPile = all.slice(mid);
|
|
}
|
|
|
|
function _resetHand() {
|
|
// Spread-switch reset only (mid-draw guard inside sync()).
|
|
// Iter 4c removed the explicit DEL-resets-hand pathway:
|
|
// pre-completion DEL is `.btn-disabled`; post-completion
|
|
// DEL routes to the guard portal → server-side delete.
|
|
_filled = 0;
|
|
_hideOk();
|
|
_unlockSpread();
|
|
cross.querySelectorAll(
|
|
'.sea-crucifix-cell.sea-pos-crown, ' +
|
|
'.sea-crucifix-cell.sea-pos-leave, ' +
|
|
'.sea-crucifix-cell.sea-pos-loom, ' +
|
|
'.sea-crucifix-cell.sea-pos-lay, ' +
|
|
'.sea-pos-cover, .sea-pos-cross'
|
|
).forEach(_emptySlot);
|
|
_reshuffleDeck();
|
|
if (window.SeaDeal && window.SeaDeal.resetHand) {
|
|
SeaDeal.resetHand();
|
|
}
|
|
}
|
|
|
|
function _setComplete(on) {
|
|
// Iter 4c — single-call state transition for "hand
|
|
// complete": DEL un-disables, action btn becomes GATE
|
|
// VIEW, FLIP buttons on the deck stacks render as
|
|
// disabled when shown. The deck STACKS themselves stay
|
|
// click-responsive (so the user can see the disabled
|
|
// FLIP feedback) — `_locked` gates the actual draw.
|
|
_locked = on;
|
|
picker.classList.toggle('my-sea-picker--locked', on);
|
|
if (delBtn) delBtn.classList.toggle('btn-disabled', !on);
|
|
if (actionBtn) {
|
|
actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw';
|
|
actionBtn.innerHTML = on ? 'GATE<br>VIEW' : 'AUTO<br>DRAW';
|
|
}
|
|
_hideOk();
|
|
}
|
|
|
|
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
|
|
// Iter 4c — stacks remain click-responsive even after hand
|
|
// is complete (so the user sees the disabled-FLIP feedback,
|
|
// signalling "no more draws allowed"). Each card placement
|
|
// POSTs the current hand to /lock for server upsert.
|
|
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
|
|
stack.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (_activeStack === stack) _hideOk();
|
|
else _showOk(stack);
|
|
});
|
|
var ok = stack.querySelector('.sea-stack-ok');
|
|
if (ok) {
|
|
ok.style.display = 'none';
|
|
ok.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
// Hand complete → FLIP is disabled. No draw.
|
|
if (_locked) { _hideOk(); return; }
|
|
var isLevity = stack.classList.contains('sea-deck-stack--levity');
|
|
var pile = isLevity ? _levityPile : _gravityPile;
|
|
var card = pile.length ? pile.shift() : null;
|
|
var order = _currentOrder();
|
|
var posName = order[_filled];
|
|
if (card && posName) {
|
|
if (window.SeaDeal && window.SeaDeal.openStage) {
|
|
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
|
|
} else {
|
|
_fillSlot(posName, card, isLevity);
|
|
}
|
|
_filled++;
|
|
if (_filled === 1) _lockSpread();
|
|
// Per-placement upsert — server stays current
|
|
// so a navigate-away mid-draw still persists
|
|
// the partial hand. User-spec 2026-05-20.
|
|
_postLock(_collectHandFromDom());
|
|
if (_filled >= order.length) {
|
|
_setComplete(true);
|
|
}
|
|
}
|
|
_hideOk();
|
|
});
|
|
}
|
|
});
|
|
|
|
function _collectHandFromDom() {
|
|
// Walks every `.sea-card-slot--filled` and reconstructs
|
|
// the hand array in DRAW_ORDER. data-card-id, polarity
|
|
// class, and `.sea-card-slot--reversed` are set at
|
|
// FLIP-click time so this is always current.
|
|
var byPos = {};
|
|
cross.querySelectorAll(
|
|
'.sea-card-slot.sea-card-slot--filled'
|
|
).forEach(function (slot) {
|
|
var cls = slot.className;
|
|
var pos = slot.dataset.posKey || '';
|
|
if (!pos) return;
|
|
byPos[pos] = {
|
|
position: pos,
|
|
card_id: parseInt(slot.dataset.cardId, 10),
|
|
reversed: /sea-card-slot--reversed\b/.test(cls),
|
|
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
|
|
};
|
|
});
|
|
// Emit in DRAW_ORDER so the server stores cards in
|
|
// chronological draw order (Sprint 7 applet reads this
|
|
// sequence left-to-right).
|
|
var hand = [];
|
|
_currentOrder().forEach(function (pos) {
|
|
if (byPos[pos]) hand.push(byPos[pos]);
|
|
});
|
|
return hand;
|
|
}
|
|
|
|
function _autoDraw() {
|
|
// Compute all remaining cards client-side + POST in one
|
|
// shot (per user spec 2026-05-20: "commit all six draws
|
|
// in the same POST, just display them in order via js"
|
|
// — survives navigate-away mid-animation). After server
|
|
// commit, animate placement sequentially.
|
|
var order = _currentOrder();
|
|
var remaining = order.length - _filled;
|
|
if (remaining <= 0) return;
|
|
var autoEntries = [];
|
|
for (var i = 0; i < remaining; i++) {
|
|
var isLevity = Math.random() < 0.5;
|
|
var pile = isLevity ? _levityPile : _gravityPile;
|
|
if (!pile.length) {
|
|
isLevity = !isLevity;
|
|
pile = isLevity ? _levityPile : _gravityPile;
|
|
}
|
|
if (!pile.length) break;
|
|
var card = pile.shift();
|
|
autoEntries.push({
|
|
card: card,
|
|
posName: order[_filled + i],
|
|
isLevity: isLevity,
|
|
});
|
|
}
|
|
var fullHand = _collectHandFromDom();
|
|
autoEntries.forEach(function (e) {
|
|
fullHand.push({
|
|
position: e.posName,
|
|
card_id: e.card.id,
|
|
reversed: !!e.card.reversed,
|
|
polarity: e.isLevity ? 'levity' : 'gravity',
|
|
});
|
|
});
|
|
_postLock(fullHand).then(function (body) {
|
|
if (!body || !body.ok) return;
|
|
// POST succeeded → animate placement client-side.
|
|
// Even if the user navigates away now, the server
|
|
// already has the full hand — reload restores the
|
|
// complete picker state.
|
|
if (autoEntries.length === 0) {
|
|
_setComplete(true);
|
|
return;
|
|
}
|
|
if (_filled === 0) _lockSpread();
|
|
var idx = 0;
|
|
function placeNext() {
|
|
if (idx >= autoEntries.length) {
|
|
_setComplete(true);
|
|
return;
|
|
}
|
|
var e = autoEntries[idx++];
|
|
var stack = picker.querySelector(
|
|
'.sea-deck-stack--' + (e.isLevity ? 'levity' : 'gravity')
|
|
);
|
|
if (stack) _showOk(stack);
|
|
setTimeout(function () {
|
|
_fillSlot(e.posName, e.card, e.isLevity);
|
|
cross.querySelector('.sea-pos-' + e.posName + ' .sea-card-slot')
|
|
.classList.add('sea-card-slot--visible');
|
|
_filled++;
|
|
_hideOk();
|
|
setTimeout(placeNext, 250);
|
|
}, 350);
|
|
}
|
|
placeNext();
|
|
});
|
|
}
|
|
|
|
// Click elsewhere inside the picker dismisses the FLIP btn.
|
|
picker.addEventListener('click', _hideOk);
|
|
|
|
// ── DEL: post-completion only (server-rendered `.btn-
|
|
// disabled` pre-completion gates pre-hand DEL attempts).
|
|
// Opens the shared `#id_guard_portal` from base.html;
|
|
// CONFIRM POSTs to /gameboard/my-sea/delete which clears
|
|
// the hand but preserves the row (quota stays committed
|
|
// for the 24h window). Server returns 204; we reload to
|
|
// land on the GATE VIEW landing (FREE DRAW is gone until
|
|
// the row expires).
|
|
if (delBtn) {
|
|
delBtn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (delBtn.classList.contains('btn-disabled')) return;
|
|
if (!window.showGuard) return;
|
|
window.showGuard(
|
|
delBtn,
|
|
'Are you sure?',
|
|
function () {
|
|
fetch('{% url "my_sea_delete" %}', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {'X-CSRFToken': _csrf()},
|
|
}).then(function (r) {
|
|
if (r.ok) window.location.reload();
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Action btn: AUTO DRAW (pre-complete) ↔ GATE VIEW
|
|
// (post-complete). Same DOM node, label + behavior keyed
|
|
// on `data-state`. AUTO DRAW opens a guard portal "Auto
|
|
// deal cards?"; on OK, fills remaining slots client-side
|
|
// + commits to server in one POST. GATE VIEW navigates
|
|
// to the Sprint-6 gatekeeper (currently 404 stub).
|
|
if (actionBtn) {
|
|
actionBtn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
var state = actionBtn.dataset.state;
|
|
if (state === 'gate-view') {
|
|
window.location.href = actionBtn.dataset.gateUrl || '#';
|
|
return;
|
|
}
|
|
// AUTO DRAW
|
|
if (!window.showGuard) return;
|
|
window.showGuard(
|
|
actionBtn,
|
|
'Auto deal cards?',
|
|
_autoDraw
|
|
);
|
|
});
|
|
}
|
|
|
|
function _csrf() {
|
|
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
|
return m ? decodeURIComponent(m[1]) : '';
|
|
}
|
|
function _formatTimestamp(iso) {
|
|
// Mirror the server-side `D, M j @ g:i A` format
|
|
// (e.g., "Thu, May 21 @ 2:41 AM").
|
|
var d = new Date(iso);
|
|
if (isNaN(d)) return '';
|
|
var DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
|
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
|
|
'Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
var h = d.getHours();
|
|
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
h = h % 12; if (h === 0) h = 12;
|
|
var m = d.getMinutes();
|
|
var mm = (m < 10 ? '0' : '') + m;
|
|
return DAYS[d.getDay()] + ', ' + MONTHS[d.getMonth()]
|
|
+ ' ' + d.getDate() + ' @ ' + h + ':' + mm + ' ' + ampm;
|
|
}
|
|
window._showFreeDrawLockedBrief = function (iso) {
|
|
// Standard Brief banner — portaled atop the h2 w.
|
|
// Gaussian-glass bg (see [[note.js]] showBanner). The
|
|
// next-free-draw moment is passed as an ISO string +
|
|
// re-used as `created_at` so note.js's `<time
|
|
// class="note-banner__timestamp">` slot renders the
|
|
// datetime instead of "Invalid Date" (which it does
|
|
// for empty/invalid input). The `line_text` carries
|
|
// only the contextual prose now — the dedicated slot
|
|
// owns the timestamp display.
|
|
if (!window.Brief || !Brief.showBanner) return;
|
|
Brief.showBanner({
|
|
title: 'Free draw locked',
|
|
line_text:
|
|
'Look!—your free draw is locked in. ' +
|
|
'Next free draw available at:',
|
|
post_url: '{% url "gameboard" %}',
|
|
created_at: iso,
|
|
kind: 'NUDGE',
|
|
});
|
|
var banner = document.querySelector('.note-banner');
|
|
if (banner) {
|
|
banner.classList.add('my-sea-locked-banner');
|
|
// note.js renders the timestamp as `toLocaleDateString`
|
|
// (e.g., "May 20, 2026") — short-form, no time. Our
|
|
// use case wants the full `D, M j @ g:i A` shape
|
|
// (e.g., "Wed, May 20 @ 11:57 PM") so the user sees
|
|
// both the date AND the precise unlock hour. Overwrite
|
|
// the rendered text in-place (leaves the `datetime=`
|
|
// attribute intact for accessibility tooling).
|
|
var ts = banner.querySelector('.note-banner__timestamp');
|
|
if (ts && iso) ts.textContent = _formatTimestamp(iso);
|
|
// No FYI on this Brief — it's an informational nudge
|
|
// (locked draw status), not a navigation target. The
|
|
// NVM button + timestamp slot carry all the affordance
|
|
// the user needs.
|
|
var fyi = banner.querySelector('.note-banner__fyi');
|
|
if (fyi) fyi.remove();
|
|
}
|
|
};
|
|
function _postLock(hand) {
|
|
// Returns the parsed JSON body promise so callers (e.g.
|
|
// _autoDraw) can chain animation onto server commit.
|
|
// Surfaces the Brief banner only on the *first* commit
|
|
// of this session (quota commit moment) — dedupe via
|
|
// `.my-sea-locked-banner` presence in DOM, so per-
|
|
// placement POSTs don't spam multiple banners.
|
|
return fetch('{% url "my_sea_lock" %}', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': _csrf(),
|
|
},
|
|
body: JSON.stringify({
|
|
spread: hidden.value,
|
|
hand: hand,
|
|
}),
|
|
}).then(function (r) {
|
|
return r.ok ? r.json() : null;
|
|
}).then(function (body) {
|
|
if (body && body.next_free_draw_at
|
|
&& !document.querySelector('.my-sea-locked-banner')) {
|
|
window._showFreeDrawLockedBrief(body.next_free_draw_at);
|
|
}
|
|
return body;
|
|
});
|
|
}
|
|
|
|
function syncLabels(spread) {
|
|
var labels = POSITION_LABELS[spread] || {};
|
|
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
|
|
var pos = el.dataset.position;
|
|
el.textContent = labels[pos] || '';
|
|
});
|
|
}
|
|
function sync() {
|
|
cross.setAttribute('data-spread', hidden.value);
|
|
syncLabels(hidden.value);
|
|
// Spread switch invalidates any in-progress hand —
|
|
// position-subset + draw-order both change. Reset.
|
|
_resetHand();
|
|
}
|
|
hidden.addEventListener('change', sync);
|
|
|
|
// Initial state — iter-4c restoration of partial / complete
|
|
// saved-hand sessions. Server pre-renders the slots from
|
|
// saved_by_position; count the filled slots to seed _filled.
|
|
// If hand is complete (server-rendered `.my-sea-picker--
|
|
// locked`), call `_setComplete(true)` to align JS state
|
|
// (decks click-responsive but FLIP arrives disabled; DEL
|
|
// enabled; action btn = GATE VIEW). Mid-draw saved hands
|
|
// just need the count + spread lock (the user resumes
|
|
// drawing from where they left off).
|
|
_filled = cross.querySelectorAll(
|
|
'.sea-card-slot.sea-card-slot--filled'
|
|
).length;
|
|
if (_filled >= _currentOrder().length) {
|
|
_setComplete(true);
|
|
_lockSpread();
|
|
} else if (_filled > 0) {
|
|
_lockSpread();
|
|
}
|
|
|
|
// Belt-and-braces autofill defense (paired w. autocomplete=
|
|
// off on the hidden input above). Firefox occasionally
|
|
// restores form-history values on soft reload even on
|
|
// hidden inputs; if it does, hidden.value diverges from
|
|
// the server-rendered aria-selected option + combobox.js
|
|
// short-circuits its change-event dispatch on subsequent
|
|
// option picks of the autofilled value. Force-sync from
|
|
// the server-rendered aria-selected source-of-truth.
|
|
var _initialOpt = document.querySelector(
|
|
'.sea-select-list [role="option"][aria-selected="true"]'
|
|
);
|
|
if (_initialOpt && hidden.value !== _initialOpt.dataset.value) {
|
|
hidden.value = _initialOpt.dataset.value;
|
|
}
|
|
// Mirror the hidden value onto the cross's `data-spread` +
|
|
// re-run syncLabels. Idempotent when server-rendered state
|
|
// is internally consistent; corrective if a prior page
|
|
// state (Firefox bfcache restoring a Celtic-Cross DOM,
|
|
// session left mid-draw etc.) left a stale `data-spread`
|
|
// that SCSS-hides the wrong subset of cells. Without this,
|
|
// a post-DEL reload could land on the picker w. data-
|
|
// spread="waite-smith" but SAO labels + SAO hidden value
|
|
// → all 6 cells visible, sometimes unlabeled (the user-
|
|
// observed bug, 2026-05-20).
|
|
cross.setAttribute('data-spread', hidden.value);
|
|
syncLabels(hidden.value);
|
|
|
|
// Exposed for iter 4b / future surfaces.
|
|
window._mySeaDrawOrder = DRAW_ORDER;
|
|
}());
|
|
</script>
|
|
|
|
<script src="{% static 'apps/epic/room.js' %}"></script>
|
|
<script>
|
|
(function () {
|
|
var page = document.querySelector('.my-sea-page');
|
|
if (!page) return;
|
|
var landing = page.querySelector('.my-sea-landing');
|
|
var picker = page.querySelector('.my-sea-picker');
|
|
var drawBtn = document.getElementById('id_draw_sea_btn');
|
|
// FREE DRAW click flow:
|
|
// 1) seat 1C transitions to .seated (chair --terUser +
|
|
// drop-shadow glow + .fa-ban → .fa-circle-check —
|
|
// _room.scss line 596 makes the colour change a
|
|
// 0.6s ease transition);
|
|
// 2) after a brief delay (so the user sees the seat
|
|
// animation), data-phase swaps to 'picker' + the
|
|
// landing hides. Picker content lands in iter 2.
|
|
// The seat-take logic is solo-coded for now: 1C is the
|
|
// lowest-numeral chair, and my-sea is 1-user-per-page
|
|
// until the friend-invite feature (per [[project-my-
|
|
// sea-roadmap]]) — so 1C is always the user's seat.
|
|
var SEAT_ANIM_MS = 800;
|
|
if (drawBtn) {
|
|
drawBtn.addEventListener('click', function () {
|
|
var seat1 = page.querySelector('.table-seat[data-slot="1"]');
|
|
if (seat1) {
|
|
seat1.classList.add('seated');
|
|
var statusIcon = seat1.querySelector('.position-status-icon');
|
|
if (statusIcon) {
|
|
statusIcon.classList.remove('fa-ban');
|
|
statusIcon.classList.add('fa-circle-check');
|
|
}
|
|
}
|
|
setTimeout(function () {
|
|
page.setAttribute('data-phase', 'picker');
|
|
if (landing) landing.style.display = 'none';
|
|
if (picker) picker.style.display = '';
|
|
}, SEAT_ANIM_MS);
|
|
});
|
|
}
|
|
// Iter 4c — landing GATE VIEW (replaces FREE DRAW when the
|
|
// user's quota row exists w. empty hand, i.e. post-DEL).
|
|
// Navigates to the Sprint-6 gatekeeper (currently 404 stub).
|
|
var gateLandingBtn = document.getElementById('id_my_sea_gate_view_btn');
|
|
if (gateLandingBtn) {
|
|
gateLandingBtn.addEventListener('click', function () {
|
|
window.location.href = gateLandingBtn.dataset.gateUrl || '#';
|
|
});
|
|
}
|
|
// Mirror my-sign's scaleTable() init timing fix — the
|
|
// .my-sea-page hasn't flushed its flex sizing on
|
|
// DOMContentLoaded, so the hex stays unscaled until we
|
|
// dispatch a resize once layout settles.
|
|
window.requestAnimationFrame(function () {
|
|
window.dispatchEvent(new Event('resize'));
|
|
});
|
|
}());
|
|
</script>
|
|
|
|
{# Brief 'Default deck warning' banner — lifted verbatim from #}
|
|
{# /billboard/my-sign/'s no-equipped-deck path. Same copy, #}
|
|
{# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#}
|
|
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
|
|
{# any other Briefs on the page. #}
|
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
|
{% if active_draw %}
|
|
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
|
|
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
|
|
{# the on-LOCK-success path inside the picker IIFE calls the #}
|
|
{# same `window._showFreeDrawLockedBrief` so a freshly-locked #}
|
|
{# hand gets the identical UX without a page reload. Pass an #}
|
|
{# ISO timestamp (`|date:'c'`) so note.js's `<time>` slot #}
|
|
{# parses cleanly instead of rendering "Invalid Date". #}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (window._showFreeDrawLockedBrief) {
|
|
window._showFreeDrawLockedBrief("{{ next_free_draw_at|date:'c' }}");
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% if show_backup_intro_banner %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (!window.Brief || !Brief.showBanner) return;
|
|
Brief.showBanner({
|
|
title: 'Default deck warning',
|
|
line_text: 'Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.',
|
|
post_url: '{% url "gameboard" %}',
|
|
created_at: '',
|
|
kind: 'NUDGE',
|
|
});
|
|
var banner = document.querySelector('.note-banner');
|
|
if (banner) banner.classList.add('my-sea-intro-banner');
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% endif %}
|
|
{# Sprint 6 iter 6c — gear-btn lives on every my-sea page state #}
|
|
{# (sign-gate / landing / picker). NVM-only menu mirrors the #}
|
|
{# gatekeeper's gear; "back out to /gameboard/" affordance. #}
|
|
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
|
|
</div>
|
|
{% endblock content %}
|