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:
@@ -726,3 +726,74 @@ class MySeaSpreadFormTemplateTest(TestCase):
|
||||
self.assertIn('id="id_sea_del"', html)
|
||||
self.assertIn("sea-reversal-hint", html)
|
||||
self.assertIn("25% reversals", html)
|
||||
|
||||
|
||||
class MySeaDeckDataViewTest(TestCase):
|
||||
"""Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view
|
||||
embeds a shuffled deck (levity + gravity halves, current user's
|
||||
significator excluded, reversal pre-rolled at ~25%) as JSON via
|
||||
the `sea_deck_data` context key + `{{ ...|json_script }}` filter
|
||||
in the template."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="deck@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
|
||||
def test_context_sea_deck_data_has_two_polarity_halves(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
self.assertIn("levity", deck)
|
||||
self.assertIn("gravity", deck)
|
||||
self.assertIsInstance(deck["levity"], list)
|
||||
self.assertIsInstance(deck["gravity"], list)
|
||||
|
||||
def test_deck_data_excludes_user_significator(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
all_ids = (
|
||||
{c["id"] for c in deck["levity"]}
|
||||
| {c["id"] for c in deck["gravity"]}
|
||||
)
|
||||
self.assertNotIn(self.target.id, all_ids)
|
||||
|
||||
def test_deck_data_halves_are_disjoint(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
levity_ids = {c["id"] for c in deck["levity"]}
|
||||
gravity_ids = {c["id"] for c in deck["gravity"]}
|
||||
self.assertEqual(levity_ids & gravity_ids, set())
|
||||
|
||||
def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self):
|
||||
# Card dict shape mirrors the gameroom `sea_deck` endpoint so
|
||||
# iter 4b's persistence/render path can reuse the JSON contract.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
any_card = (deck["levity"] + deck["gravity"])[0]
|
||||
for key in ("id", "corner_rank", "suit_icon", "reversed"):
|
||||
with self.subTest(key=key):
|
||||
self.assertIn(key, any_card)
|
||||
self.assertIsInstance(any_card["reversed"], bool)
|
||||
|
||||
def test_template_embeds_deck_as_json_script(self):
|
||||
# Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}`
|
||||
# gives a `<script type="application/json" id="id_my_sea_deck">`.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(
|
||||
response,
|
||||
'<script id="id_my_sea_deck" type="application/json">',
|
||||
)
|
||||
|
||||
def test_deck_data_empty_when_user_has_no_equipped_deck(self):
|
||||
# Backup-deck branch: per [[sprint-my-sign-picker-may18h]] follow-
|
||||
# up, no-deck users still proceed via Earthman. So deck_data falls
|
||||
# back to Earthman, NOT empty. (Earthman seed is migration-loaded
|
||||
# in this TestCase context.)
|
||||
self.user.equipped_deck = None
|
||||
self.user.save(update_fields=["equipped_deck"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
deck = response.context["sea_deck_data"]
|
||||
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
||||
|
||||
@@ -198,10 +198,66 @@ def my_sea(request):
|
||||
# is a placeholder UI value pending the per-user setting.
|
||||
"default_spread": "situation-action-outcome",
|
||||
"reversals_pct": 25,
|
||||
# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, sig
|
||||
# excluded) for the client-side card-draw mechanic. Embedded in
|
||||
# the template via `{{ sea_deck_data|json_script:"..." }}`; JS
|
||||
# reads on init + maintains the in-progress hand state client-
|
||||
# side. Persistence (LOCK HAND → POST) lands in iter 4b.
|
||||
"sea_deck_data": _my_sea_deck_data(request.user) if user_has_sig else {"levity": [], "gravity": []},
|
||||
"page_class": "page-gameboard page-my-sea",
|
||||
})
|
||||
|
||||
|
||||
def _my_sea_deck_data(user):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Mirrors the gameroom `epic.views.sea_
|
||||
deck` endpoint's card_dict shape so iter 4b's render/persist path
|
||||
can reuse the same JSON contract.
|
||||
|
||||
Differences from the room version:
|
||||
- No `room` context — exclude only the current user's significator
|
||||
(no other seated gamers to worry about).
|
||||
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
|
||||
fall back to Earthman (mirrors `personal_sig_cards`).
|
||||
- Reversal probability hardcoded to 0.25 per the iter 3 spec lock;
|
||||
future per-user config rides on the shared `stack_reversal_
|
||||
probability` helper.
|
||||
"""
|
||||
import random
|
||||
from apps.epic.models import DeckVariant, TarotCard
|
||||
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not deck:
|
||||
return {"levity": [], "gravity": []}
|
||||
available = list(TarotCard.objects.filter(deck_variant=deck))
|
||||
if user.significator_id:
|
||||
available = [c for c in available if c.id != user.significator_id]
|
||||
random.shuffle(available)
|
||||
mid = len(available) // 2
|
||||
reversal_prob = 0.25
|
||||
|
||||
def _card_dict(c):
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"arcana": c.arcana,
|
||||
"suit": c.suit,
|
||||
"number": c.number,
|
||||
"corner_rank": c.corner_rank,
|
||||
"suit_icon": c.suit_icon,
|
||||
"name_group": c.name_group,
|
||||
"name_title": c.name_title,
|
||||
"levity_qualifier": c.levity_qualifier,
|
||||
"gravity_qualifier": c.gravity_qualifier,
|
||||
"reversal_qualifier": c.reversal_qualifier,
|
||||
"reversed": random.random() < reversal_prob,
|
||||
}
|
||||
|
||||
return {
|
||||
"levity": [_card_dict(c) for c in available[:mid]],
|
||||
"gravity": [_card_dict(c) for c in available[mid:]],
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def tarot_fan(request, deck_id):
|
||||
from apps.epic.models import TarotCard
|
||||
|
||||
@@ -636,6 +636,232 @@ class MySeaSpreadFormTest(FunctionalTest):
|
||||
)
|
||||
return el
|
||||
|
||||
|
||||
class MySeaCardDrawTest(FunctionalTest):
|
||||
"""Sprint 5 iter 4a — client-side card-draw mechanics on the picker
|
||||
phase. Server embeds the deck (gravity + levity halves, user's sig
|
||||
excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP
|
||||
deposits the next card into the next DRAW_ORDER slot for the active
|
||||
spread. DEL fully resets the in-progress hand. LOCK HAND enables
|
||||
when the hand is complete + click locks down further interaction.
|
||||
Switching spreads also resets the hand (the position-subset changes).
|
||||
|
||||
Server-side persistence (committing the locked hand to a MySeaDraw
|
||||
model) defers to iter 4b."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "draw@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
self.target_card = _assign_sig(self.gamer)
|
||||
|
||||
def _enter_picker_phase(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
||||
)
|
||||
btn.click()
|
||||
return self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
|
||||
def _draw_one(self, picker, polarity):
|
||||
"""Click a polarity swatch + the FLIP btn that appears →
|
||||
deposits a card. `polarity` is `'levity'` or `'gravity'`."""
|
||||
stack = picker.find_element(
|
||||
By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
|
||||
)
|
||||
stack.click()
|
||||
flip = self.wait_for(
|
||||
lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
|
||||
)
|
||||
# FLIP btn becomes visible after the stack click; wait for it.
|
||||
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
|
||||
flip.click()
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_deck_data_embedded_with_two_polarity_halves(self):
|
||||
"""Server-side renders the shuffled deck (levity + gravity
|
||||
halves, sig excluded) inside `<script type="application/json"
|
||||
id="id_my_sea_deck">`. Client-side JS reads on init."""
|
||||
import json as _json
|
||||
picker = self._enter_picker_phase()
|
||||
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
||||
deck = _json.loads(data_el.get_attribute("textContent"))
|
||||
self.assertIn("levity", deck)
|
||||
self.assertIn("gravity", deck)
|
||||
self.assertIsInstance(deck["levity"], list)
|
||||
self.assertIsInstance(deck["gravity"], list)
|
||||
# Both halves should be non-empty (16 court cards in the seed,
|
||||
# minus 1 sig → 15 cards split ~7/8).
|
||||
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
||||
# No card appears in both halves.
|
||||
levity_ids = {c["id"] for c in deck["levity"]}
|
||||
gravity_ids = {c["id"] for c in deck["gravity"]}
|
||||
self.assertEqual(levity_ids & gravity_ids, set())
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_user_significator_excluded_from_drawn_deck(self):
|
||||
"""The user's significator (pinned in `.sea-pos-core`) must NOT
|
||||
appear in the gravity or levity deck halves — would otherwise
|
||||
let the same card show up twice in the layout."""
|
||||
import json as _json
|
||||
picker = self._enter_picker_phase()
|
||||
data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
|
||||
deck = _json.loads(data_el.get_attribute("textContent"))
|
||||
all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
|
||||
self.assertNotIn(self.target_card.id, all_ids)
|
||||
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
|
||||
"""Default spread = SAO; first slot = `.sea-pos-lay` per the
|
||||
DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
|
||||
lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
|
||||
classes + corner_rank text content from the deck card."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
slot = self.wait_for(
|
||||
lambda: picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
|
||||
)
|
||||
)
|
||||
self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
|
||||
# Card has a corner-rank rendered inside.
|
||||
slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
|
||||
# Slot has a data-card-id attribute set to the deposited card's id.
|
||||
self.assertTrue(slot.get_attribute("data-card-id"))
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_two_draws_fill_first_two_slots_in_draw_order(self):
|
||||
"""SAO draw order = lay → cover → crown. Second draw lands in
|
||||
`.sea-pos-cover` regardless of polarity. Polarity of each
|
||||
slot reflects which swatch was clicked."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
# First slot (lay) — levity
|
||||
lay = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||||
)
|
||||
self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
|
||||
# Second slot (cover) — gravity
|
||||
cover = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
|
||||
)
|
||||
self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_enables_when_sao_hand_is_complete(self):
|
||||
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
|
||||
positions are drawn (hand-size = 3 for any three-card spread)."""
|
||||
picker = self._enter_picker_phase()
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
# Two draws — still disabled.
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
self._draw_one(picker, "gravity")
|
||||
# Third draw completes the SAO hand — LOCK HAND enables.
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
)
|
||||
|
||||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_click_resets_hand_and_disables_lock_hand(self):
|
||||
"""DEL fully resets — every filled slot returns to `--empty`,
|
||||
labels re-render, _filled counter zeros, LOCK HAND disables."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
2,
|
||||
)
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
)
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
|
||||
# ── Test 7 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_click_disables_further_interaction(self):
|
||||
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
|
||||
itself all carry the `.btn-disabled` class so the hand can't
|
||||
be mutated further. Persistence (POST to a server endpoint)
|
||||
defers to iter 4b — this test pins only the visual lock."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
)
|
||||
lock.click()
|
||||
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
||||
)
|
||||
)
|
||||
# Swatches no longer respond — clicking them does nothing.
|
||||
gravity_stack = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
||||
)
|
||||
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
|
||||
|
||||
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_switching_spread_resets_in_progress_hand(self):
|
||||
"""Picking a different spread on the combobox mid-draw resets
|
||||
the hand — different spreads use different position subsets +
|
||||
different hand-sizes, so an in-progress hand can't carry over."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
1,
|
||||
)
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
combo.click()
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
".sea-select-list [role='option'][data-value='mind-body-spirit']",
|
||||
).click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):
|
||||
|
||||
@@ -371,3 +371,17 @@ body.page-gameboard {
|
||||
flex: 0 0 16rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
// LOCK HAND post-commit visual-lock: dim everything that mutates the
|
||||
// hand. `.btn-disabled` is the project's existing soft-disabled
|
||||
// treatment per [[feedback_btn_disabled_pointer_events]] — pointer-
|
||||
// events:none + opacity reduction. The deck stacks aren't buttons
|
||||
// themselves so we apply the class manually + the rule below ensures
|
||||
// they stop responding to clicks.
|
||||
.my-sea-picker--locked {
|
||||
.sea-deck-stack.btn-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user