My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD

Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.

**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:

- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)

Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).

`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.

**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.

**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).

Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.

**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-19 20:02:20 -04:00
parent f154d660bd
commit ca2a62fd84
5 changed files with 538 additions and 6 deletions

View File

@@ -181,6 +181,10 @@
</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. #}
{{ sea_deck_data|json_script:"id_my_sea_deck" }}
<script src="{% static 'apps/epic/combobox.js' %}"></script>
<script>
(function () {
@@ -190,9 +194,6 @@
// specific order. The Celtic Cross variants share position
// labels (Crown/Beneath/Cover/Cross/Before/Behind — gameroom
// vocabulary) but differ in draw order.
//
// DRAW_ORDER feeds iter 4's deck-click-deposit logic; for
// iter 3 it's just baked-in metadata.
var DRAW_ORDER = {
'past-present-future': ['leave', 'cover', 'loom'],
'situation-action-outcome': ['lay', 'cover', 'crown'],
@@ -211,7 +212,162 @@
};
var hidden = document.getElementById('id_sea_spread');
var cross = document.querySelector('.my-sea-cross');
if (!hidden || !cross) return;
var picker = document.querySelector('.my-sea-picker');
var lockBtn= document.getElementById('id_sea_lock_hand');
var delBtn = document.getElementById('id_sea_del');
var deckEl = document.getElementById('id_my_sea_deck');
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 w. the .sea-pos-label re-rendered inside.
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;
var posName = '';
cell.classList.forEach(function (cls) {
var m = /^sea-pos-(.+)$/.exec(cls);
if (m && m[1] !== 'core' && m[1] !== 'label') posName = m[1];
});
var labels = POSITION_LABELS[hidden.value] || {};
slot.innerHTML =
'<span class="sea-pos-label" data-position="' + posName + '">' +
(labels[posName] || '') +
'</span>';
}
function _resetHand() {
_filled = 0;
_hideOk();
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);
_levityPile = (_deckData.levity || []).slice();
_gravityPile = (_deckData.gravity || []).slice();
if (lockBtn) lockBtn.disabled = true;
}
function _setLocked(on) {
_locked = on;
picker.classList.toggle('my-sea-picker--locked', on);
[picker.querySelector('.sea-deck-stack--levity'),
picker.querySelector('.sea-deck-stack--gravity'),
delBtn, lockBtn].forEach(function (el) {
if (!el) return;
el.classList.toggle('btn-disabled', on);
});
_hideOk();
}
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
stack.addEventListener('click', function (e) {
if (_locked) return;
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) {
if (_locked) return;
e.stopPropagation();
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) {
_fillSlot(posName, card, isLevity);
_filled++;
if (lockBtn) lockBtn.disabled = (_filled < order.length);
}
_hideOk();
});
}
});
// Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk);
if (delBtn) {
delBtn.addEventListener('click', function () {
if (_locked) return;
_resetHand();
});
}
if (lockBtn) {
lockBtn.addEventListener('click', function () {
if (lockBtn.disabled) return;
_setLocked(true);
});
}
function syncLabels(spread) {
var labels = POSITION_LABELS[spread] || {};
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
@@ -222,10 +378,19 @@
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);
sync();
// Exposed for iter 4 (deck-click-deposit reads DRAW_ORDER).
// Initial state — labels already server-rendered for the
// default spread; we just zero the hand counter + ensure
// LOCK HAND starts disabled.
_filled = 0;
if (lockBtn) lockBtn.disabled = true;
// Exposed for iter 4b / future surfaces.
window._mySeaDrawOrder = DRAW_ORDER;
}());
</script>