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:
19
src/templates/apps/gameboard/_partials/_my_sea_slot.html
Normal file
19
src/templates/apps/gameboard/_partials/_my_sea_slot.html
Normal 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 %}
|
||||
@@ -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!—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
|
||||
|
||||
Reference in New Issue
Block a user