My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD

Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).

Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.

Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-19 23:54:00 -04:00
parent 31ed2bda0e
commit b76d3c5dff
13 changed files with 1147 additions and 145 deletions

View File

@@ -0,0 +1,19 @@
{# Renders a single .sea-card-slot for the my-sea picker — either #}
{# an empty drop zone (default) or a server-pre-filled slot from a #}
{# saved MySeaDraw row. Used by iter 4b's saved-hand bypass. #}
{# #}
{# Args: #}
{# position — slug of the position (lay/cover/crown/leave/loom/cross) #}
{# saved — saved_by_position[position] | dict | None #}
{# crossing — bool; pass True for the cross slot (gets the #}
{# `.sea-card-slot--crossing` modifier in iter-4a HTML) #}
{% if saved %}
<div class="sea-card-slot sea-card-slot--filled sea-card-slot--visible sea-card-slot--{{ saved.polarity }}{% if saved.reversed %} sea-card-slot--reversed{% endif %}{% if crossing %} sea-card-slot--crossing{% endif %}"
data-card-id="{{ saved.card_id }}"
data-pos-key="{{ position }}">
<span class="fan-corner-rank">{{ saved.corner_rank }}</span>
{% if saved.suit_icon %}<i class="fa-solid {{ saved.suit_icon }}"></i>{% endif %}
</div>
{% else %}
<div class="sea-card-slot sea-card-slot--empty{% if crossing %} sea-card-slot--crossing{% endif %}"></div>
{% endif %}

View File

@@ -5,7 +5,7 @@
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %}
<div class="my-sea-page" data-phase="landing">
<div class="my-sea-page" data-phase="{% if active_draw %}picker{% else %}landing{% 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. #}
@@ -24,12 +24,15 @@
</div>
</div>
{% else %}
{% if not active_draw %}
{# 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". DRAW SEA btn #}
{# mirrors SCAN SIGN on /billboard/my-sign/. #}
{# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #}
{# an active draw exists (iter 4b) — the picker phase is the #}
{# landing state once the user has spent their free quota. #}
<div class="my-sea-landing">
<div class="room-shell">
<div id="id_game_table" class="room-table">
@@ -65,6 +68,7 @@
</div>
</div>
</div>
{% endif %}
{# Picker phase — per-spread flexible layout. Sig pins .sea- #}
{# pos-core; the 6 surrounding positions all render in DOM #}
@@ -82,7 +86,7 @@
{# 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" id="id_sea_overlay" style="display:none">
<div class="my-sea-picker{% if active_draw %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not active_draw %} 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 #}
@@ -92,11 +96,11 @@
{# touch the slot's nearest edge. #}
<div class="sea-crucifix-cell sea-pos-crown">
<span class="sea-pos-label" data-position="crown">Outcome</span>
<div class="sea-card-slot sea-card-slot--empty"></div>
{% 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>
<div class="sea-card-slot sea-card-slot--empty"></div>
{% 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"
@@ -106,20 +110,20 @@
</div>
<div class="sea-pos-cover">
<span class="sea-pos-label" data-position="cover">Action</span>
<div class="sea-card-slot sea-card-slot--empty"></div>
{% 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>
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
{% 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>
<div class="sea-card-slot sea-card-slot--empty"></div>
{% 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>
<div class="sea-card-slot sea-card-slot--empty"></div>
{% include "apps/gameboard/_partials/_my_sea_slot.html" with position="lay" saved=saved_by_position.lay crossing=False %}
</div>
</div>
</div>
@@ -200,6 +204,40 @@
{# thumbnail. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>
{# Iter 4b — Look!-formatted Brief banner above the picker. #}
{# Always rendered when the picker is rendered, but hidden #}
{# unless a saved draw occupies the user's free-quota slot #}
{# (server) OR LOCK HAND just fired (client un-hides on the #}
{# fetch response w. the next-free-draw timestamp). Avoiding #}
{# a full page reload on LOCK lets the iter-4a FTs keep their #}
{# picker element refs valid post-lock. #}
<div class="my-sea-brief"{% if not active_draw %} hidden{% endif %}>
<p class="my-sea-brief__line">
Look!&mdash;your free draw is locked in for the next 24 hours. Next free draw available at
<time class="my-sea-brief__timestamp"
datetime="{% if next_free_draw_at %}{{ next_free_draw_at|date:'c' }}{% endif %}">{% if next_free_draw_at %}{{ next_free_draw_at|date:'D, M j @ g:i A' }}{% endif %}</time>.
</p>
<button type="button" class="btn btn-cancel my-sea-brief__nvm">NVM</button>
</div>
{# Iter 4b — DEL guard portal. Uniform 'Are you sure?' copy #}
{# regardless of quota state (the Brief banner above carries #}
{# the quota-specific info). Always rendered (hidden by #}
{# default); DEL click un-hides when picker is `_locked`. #}
{# CONFIRM POSTs to /gameboard/my-sea/delete; NVM dismisses. #}
<div id="id_my_sea_del_portal" class="my-sea-del-portal" hidden>
<div class="my-sea-del-portal__panel">
<p class="my-sea-del-portal__line">Are you sure?</p>
<div class="my-sea-del-portal__actions">
<button type="button"
class="btn btn-cancel my-sea-del-portal__nvm">NVM</button>
<button type="button"
class="btn btn-danger my-sea-del-portal__confirm"
data-delete-url="{% url 'my_sea_delete' %}">CONFIRM</button>
</div>
</div>
</div>
{# 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. #}
@@ -385,9 +423,15 @@
function _setLocked(on) {
_locked = on;
picker.classList.toggle('my-sea-picker--locked', on);
// Decks + LOCK HAND go disabled; DEL stays interactive
// — it's the user's escape from the locked state
// (opens the guard portal in iter 4b, resets the hand
// in iter 4a). Without this exemption the SCSS rule
// `.btn-disabled { pointer-events: none }` would block
// every Selenium / user click on DEL post-LOCK.
[picker.querySelector('.sea-deck-stack--levity'),
picker.querySelector('.sea-deck-stack--gravity'),
delBtn, lockBtn].forEach(function (el) {
lockBtn].forEach(function (el) {
if (!el) return;
el.classList.toggle('btn-disabled', on);
});
@@ -445,19 +489,142 @@
// Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk);
// ── DEL semantics differ by lock state ──────────────────
// Pre-lock: DEL resets the in-progress hand client-side
// (iter 4a behaviour — no server round-trip).
// Post-lock: DEL opens `#id_my_sea_del_portal` guard portal
// (iter 4b). The portal CONFIRM POSTs to
// /gameboard/my-sea/delete; NVM closes the
// portal.
if (delBtn) {
delBtn.addEventListener('click', function () {
if (_locked) return;
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
var portal = document.getElementById('id_my_sea_del_portal');
if (_locked && portal) {
portal.hidden = false;
return;
}
_resetHand();
});
}
if (lockBtn) {
lockBtn.addEventListener('click', function () {
if (lockBtn.disabled) return;
if (lockBtn.disabled || _locked) return;
// Collect the in-progress hand for the POST. Slot
// class includes `--levity` / `--gravity`; reversed
// is on `.sea-card-slot--reversed`. Position comes
// from `data-pos-key`. Card id from `data-card-id`.
var hand = [];
cross.querySelectorAll(
'.sea-card-slot.sea-card-slot--filled'
).forEach(function (slot) {
var cls = slot.className;
hand.push({
position: slot.dataset.posKey || '',
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',
});
});
var order = _currentOrder();
if (hand.length < order.length) return;
_setLocked(true);
_postLock(hand);
});
}
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 used in
// the template's pre-rendered next-free-draw timestamp
// (e.g., "Thu, May 21 @ 2:41 AM"). Keeps the post-LOCK
// visual consistent with a fresh-page-load saved-draw
// render.
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;
}
function _revealBrief(nextFreeDrawIso) {
var brief = document.querySelector('.my-sea-brief');
if (!brief) return;
var ts = brief.querySelector('.my-sea-brief__timestamp');
if (ts && nextFreeDrawIso) {
ts.setAttribute('datetime', nextFreeDrawIso);
ts.textContent = _formatTimestamp(nextFreeDrawIso);
}
brief.hidden = false;
}
function _postLock(hand) {
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) {
_revealBrief(body.next_free_draw_at);
}
});
}
// ── DEL guard portal wiring ────────────────────────────
var portal = document.getElementById('id_my_sea_del_portal');
if (portal) {
var nvmBtn = portal.querySelector('.my-sea-del-portal__nvm');
var confirmBtn = portal.querySelector('.my-sea-del-portal__confirm');
if (nvmBtn) {
nvmBtn.addEventListener('click', function (e) {
e.stopPropagation();
portal.hidden = true;
});
}
if (confirmBtn) {
confirmBtn.addEventListener('click', function (e) {
e.stopPropagation();
var url = confirmBtn.dataset.deleteUrl || '';
if (!url) return;
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': _csrf()},
}).then(function (r) {
if (r.ok) window.location.reload();
});
});
}
}
// ── Brief banner NVM ──────────────────────────────────
var brief = document.querySelector('.my-sea-brief');
if (brief) {
var briefNvm = brief.querySelector('.my-sea-brief__nvm');
if (briefNvm) {
briefNvm.addEventListener('click', function () {
brief.hidden = true;
});
}
}
function syncLabels(spread) {
var labels = POSITION_LABELS[spread] || {};
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
@@ -475,10 +642,19 @@
hidden.addEventListener('change', sync);
// Initial state — labels already server-rendered for the
// default spread; we just zero the hand counter + ensure
// LOCK HAND starts disabled.
// default spread. If the picker was server-rendered w. a
// locked saved hand (iter 4b's active_draw branch), the
// .my-sea-picker--locked class is already on the page —
// mirror it in JS state so further interactions stay
// disabled + DEL routes to the guard portal.
_filled = 0;
if (lockBtn) lockBtn.disabled = true;
var _preLocked = picker.classList.contains('my-sea-picker--locked');
if (_preLocked) {
_filled = _currentOrder().length;
_setLocked(true);
_lockSpread();
}
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
// Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally