My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD
Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.
## Server
`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").
`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.
`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.
`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.
`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn
## Template + UX
- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
- `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
- `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
- JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.
## Other polish bundled
- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `×` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).
## Test coverage
- ITs (1100 IT/UT green in 57s):
- `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
- `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
- `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
- `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
- `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
- `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
- `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
- Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
- Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
- Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
- Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
- Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
- Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.
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:
@@ -5,7 +5,9 @@
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="my-sea-page" data-phase="{% if active_draw %}picker{% else %}landing{% endif %}">
|
||||
<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. #}
|
||||
@@ -24,15 +26,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if not active_draw %}
|
||||
{% 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". DRAW SEA btn #}
|
||||
{# 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. #}
|
||||
{# anchor "Six chairs retained even in solo". #}
|
||||
{# #}
|
||||
{# Iter 4c — landing primary btn is: #}
|
||||
{# • FREE DRAW (`#id_draw_sea_btn`) when the user has no #}
|
||||
{# active quota row (fresh user OR >24h since last draw); #}
|
||||
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
|
||||
{# quota row exists but the hand is empty (post-DEL). The #}
|
||||
{# daily free draw is spent; further draws require a token #}
|
||||
{# deposit via the Sprint-6 gatekeeper (currently 404). #}
|
||||
<div class="my-sea-landing">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
@@ -40,13 +47,14 @@
|
||||
<div class="table-hex-border">
|
||||
<div class="table-hex">
|
||||
<div class="table-center">
|
||||
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #}
|
||||
{# Future sprint will conditionally swap this for a DRAW SEA #}
|
||||
{# .btn-primary that calls the gatekeeper partial once the #}
|
||||
{# free daily has been used; until then the btn renders FREE #}
|
||||
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #}
|
||||
{# entry point) so the swap is label-only when iter 6+ lands. #}
|
||||
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
|
||||
{% if 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>
|
||||
@@ -86,7 +94,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{% if active_draw %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not active_draw %} style="display:none"{% endif %}>
|
||||
<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 #}
|
||||
@@ -190,10 +198,26 @@
|
||||
</div>
|
||||
|
||||
<div class="sea-form-actions">
|
||||
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
|
||||
LOCK HAND
|
||||
</button>
|
||||
<button type="button" id="id_sea_del" class="btn btn-danger">
|
||||
{# 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>
|
||||
@@ -252,7 +276,7 @@
|
||||
var hidden = document.getElementById('id_sea_spread');
|
||||
var cross = document.querySelector('.my-sea-cross');
|
||||
var picker = document.querySelector('.my-sea-picker');
|
||||
var lockBtn= document.getElementById('id_sea_lock_hand');
|
||||
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"]');
|
||||
@@ -373,6 +397,10 @@
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -384,37 +412,35 @@
|
||||
'.sea-pos-cover, .sea-pos-cross'
|
||||
).forEach(_emptySlot);
|
||||
_reshuffleDeck();
|
||||
if (lockBtn) lockBtn.disabled = true;
|
||||
// Wipe SeaDeal's stage state too — closes a lingering
|
||||
// modal + clears its `_seaHand` map so previously-
|
||||
// drawn cards can't reopen via slot-tap focus.
|
||||
if (window.SeaDeal && window.SeaDeal.resetHand) {
|
||||
SeaDeal.resetHand();
|
||||
}
|
||||
}
|
||||
|
||||
function _setLocked(on) {
|
||||
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);
|
||||
// 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'),
|
||||
lockBtn].forEach(function (el) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('btn-disabled', 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) {
|
||||
if (_locked) return;
|
||||
e.stopPropagation();
|
||||
if (_activeStack === stack) _hideOk();
|
||||
else _showOk(stack);
|
||||
@@ -423,68 +449,149 @@
|
||||
if (ok) {
|
||||
ok.style.display = 'none';
|
||||
ok.addEventListener('click', function (e) {
|
||||
if (_locked) return;
|
||||
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) {
|
||||
// Delegate to SeaDeal — it `_fillSlot`s
|
||||
// (sets corner-rank + suit-icon + polarity
|
||||
// class on the slot at opacity 0) AND opens
|
||||
// the portaled stage modal w. SPIN / FYI.
|
||||
// Click-the-backdrop dismisses + the slot
|
||||
// fades to `.--visible` revealing the
|
||||
// thumbnail.
|
||||
if (window.SeaDeal && window.SeaDeal.openStage) {
|
||||
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
|
||||
} else {
|
||||
// Defensive fallback for environments
|
||||
// where sea.js failed to load (e.g.
|
||||
// collectstatic miss). Render the slot
|
||||
// visibly so the draw isn't lost.
|
||||
_fillSlot(posName, card, isLevity);
|
||||
}
|
||||
_filled++;
|
||||
// First deposit locks the SPREAD combobox —
|
||||
// switching mid-draw would scramble the
|
||||
// in-progress hand's position mapping.
|
||||
if (_filled === 1) _lockSpread();
|
||||
if (lockBtn) lockBtn.disabled = (_filled < order.length);
|
||||
// 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 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 invokes the shared `#id_guard_portal`
|
||||
// from base.html via `window.showGuard`, w. a
|
||||
// "DEL" YES-label override (the room's gear-
|
||||
// menu DEL flow uses the same portal). On YES
|
||||
// we POST to /gameboard/my-sea/delete then
|
||||
// navigate back to the page (server returns
|
||||
// 204; we redirect manually to land on the
|
||||
// FREE DRAW landing).
|
||||
// ── 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 (!_locked) {
|
||||
_resetHand();
|
||||
return;
|
||||
}
|
||||
if (delBtn.classList.contains('btn-disabled')) return;
|
||||
if (!window.showGuard) return;
|
||||
// Trigger btn (DEL, `.btn-danger`) opens the shared
|
||||
// guard portal; the portal's confirm button is the
|
||||
// standard `.btn-confirm` "OK" + `.btn-cancel` "NVM"
|
||||
// pair — matches the room gear-menu DEL flow exactly.
|
||||
window.showGuard(
|
||||
delBtn,
|
||||
'Are you sure?',
|
||||
@@ -500,29 +607,28 @@
|
||||
);
|
||||
});
|
||||
}
|
||||
if (lockBtn) {
|
||||
lockBtn.addEventListener('click', function () {
|
||||
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);
|
||||
|
||||
// ── 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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,7 +693,13 @@
|
||||
}
|
||||
};
|
||||
function _postLock(hand) {
|
||||
fetch('{% url "my_sea_lock" %}', {
|
||||
// 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: {
|
||||
@@ -601,9 +713,11 @@
|
||||
}).then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(function (body) {
|
||||
if (body && body.next_free_draw_at) {
|
||||
if (body && body.next_free_draw_at
|
||||
&& !document.querySelector('.my-sea-locked-banner')) {
|
||||
window._showFreeDrawLockedBrief(body.next_free_draw_at);
|
||||
}
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -623,20 +737,24 @@
|
||||
}
|
||||
hidden.addEventListener('change', sync);
|
||||
|
||||
// Initial state — labels already server-rendered for the
|
||||
// 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;
|
||||
var _preLocked = picker.classList.contains('my-sea-picker--locked');
|
||||
if (_preLocked) {
|
||||
_filled = _currentOrder().length;
|
||||
_setLocked(true);
|
||||
// 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();
|
||||
}
|
||||
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
|
||||
|
||||
// Belt-and-braces autofill defense (paired w. autocomplete=
|
||||
// off on the hidden input above). Firefox occasionally
|
||||
@@ -709,6 +827,15 @@
|
||||
}, 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
|
||||
|
||||
Reference in New Issue
Block a user