Files
python-tdd/src/templates/apps/gameboard/my_sea.html
Disco DeDisco 4417b8c972 My Sea iter 6c: bud-btn invite stub + #id_my_sea_menu gear (NVM-only, %applet-menu-styled, on both /gameboard/my-sea/ and the gatekeeper) + PAID DRAW now deletes the row and redirects to ?phase=picker so the user drops straight into picking cards instead of looping back to GATE VIEW — Sprint 5 iter 6c of My Sea roadmap — TDD
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>
2026-05-20 09:47:47 -04:00

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!&mdash;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!&mdash;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 %}