Compare commits
9 Commits
c8a603484e
...
31ed2bda0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ed2bda0e | ||
|
|
b6e93b9d64 | ||
|
|
ca2a62fd84 | ||
|
|
f154d660bd | ||
|
|
fd5db951a7 | ||
|
|
f5fc1e15f8 | ||
|
|
285597b467 | ||
|
|
de48ae226d | ||
|
|
4d1c74a2af |
@@ -512,3 +512,288 @@ class MySeaViewTest(TestCase):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertIn("page-gameboard", response.content.decode())
|
||||
self.assertIn("page-my-sea", response.content.decode())
|
||||
|
||||
|
||||
class MySeaDrawSeaLandingViewTest(TestCase):
|
||||
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
|
||||
`no_equipped_deck` + `show_backup_intro_banner` context keys + the
|
||||
presence of the new landing template elements when user passes the
|
||||
Sprint 4b sign-gate."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="draw@test.io")
|
||||
self.client.force_login(self.user)
|
||||
# Assign a sig so the view's landing branch (not the gate) renders.
|
||||
self.user.significator = personal_sig_cards(self.user)[0]
|
||||
self.user.save(update_fields=["significator"])
|
||||
|
||||
def test_context_no_equipped_deck_false_when_user_has_deck(self):
|
||||
# post_save auto-equips Earthman; `no_equipped_deck` should be False.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertFalse(response.context["no_equipped_deck"])
|
||||
|
||||
def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
|
||||
self.user.equipped_deck = None
|
||||
self.user.save(update_fields=["equipped_deck"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertTrue(response.context["no_equipped_deck"])
|
||||
|
||||
def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
|
||||
# Brief banner fires when user has a sig AND no deck — they're on the
|
||||
# landing UX (gate passed) but headed for the backup-deck draw path.
|
||||
self.user.equipped_deck = None
|
||||
self.user.save(update_fields=["equipped_deck"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertTrue(response.context["show_backup_intro_banner"])
|
||||
|
||||
def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertFalse(response.context["show_backup_intro_banner"])
|
||||
|
||||
def test_landing_renders_free_draw_btn_when_sig_set(self):
|
||||
# Element ID `id_draw_sea_btn` describes intent (draw entry point);
|
||||
# visible label is "FREE DRAW" for the daily-free quota draw.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'id="id_draw_sea_btn"')
|
||||
self.assertContains(response, "FREE")
|
||||
self.assertContains(response, "DRAW")
|
||||
|
||||
def test_landing_renders_six_chair_seats_with_C_suffix(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
for n in range(1, 7):
|
||||
with self.subTest(slot=n):
|
||||
self.assertIn(f'data-slot="{n}"', html)
|
||||
self.assertIn(f"{n}C", html)
|
||||
|
||||
def test_landing_renders_position_status_ban_icon_on_each_seat(self):
|
||||
# Each chair seat starts empty (red `.fa-ban` status icon). The
|
||||
# FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
|
||||
# client-side; this IT only pins the initial render state. Class
|
||||
# substrings ("position-status-icon", "fa-ban") ALSO appear in the
|
||||
# inline JS handler (classList.remove arg, querySelector arg) — so
|
||||
# counts are asserted on the full class-attribute string only.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
|
||||
self.assertEqual(html.count('class="seat-position-label"'), 6)
|
||||
|
||||
def test_landing_not_rendered_when_user_has_no_sig(self):
|
||||
# Sprint 4b gate still wins precedence — FREE DRAW must not render
|
||||
# when significator is None.
|
||||
self.user.significator = None
|
||||
self.user.save(update_fields=["significator"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
|
||||
|
||||
class MySeaPickerPhaseTemplateTest(TestCase):
|
||||
"""Sprint 5 iter 2 — picker-phase template render contract: the
|
||||
three-card cross (sig in core + cover/leave/loom drop zones) is
|
||||
server-rendered (hidden until JS swaps data-phase after FREE DRAW).
|
||||
Crown / lay / cross from the gameroom's 6-position Celtic Cross are
|
||||
deliberately forsaken in the solo flow."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="picker@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_picker_renders_significator_in_core_cell(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
# Sig card carries the user's significator id so iter 3's draw
|
||||
# flow can target it for SPIN / FLIP / FYI without re-fetching.
|
||||
self.assertIn('sea-pos-core', html)
|
||||
self.assertIn('sea-sig-card', html)
|
||||
self.assertIn(f'data-card-id="{self.target.id}"', html)
|
||||
|
||||
def test_picker_renders_cover_leave_loom_positions(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, "sea-pos-cover")
|
||||
self.assertContains(response, "sea-pos-leave")
|
||||
self.assertContains(response, "sea-pos-loom")
|
||||
|
||||
def test_picker_renders_six_card_only_positions_for_spread_switch(self):
|
||||
# Crown / lay / cross sit in the DOM unconditionally so iter 3's
|
||||
# SPREAD dropdown can reveal them via CSS attribute swap (data-
|
||||
# spread-shape="six-card") without re-rendering. Default 3-card
|
||||
# spread hides them via `.my-sea-cross[data-spread-shape=
|
||||
# "three-card"]` rules in _gameboard.scss — FT pins the hidden
|
||||
# state visually.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, "sea-pos-crown")
|
||||
self.assertContains(response, "sea-pos-lay")
|
||||
self.assertContains(response, "sea-pos-cross")
|
||||
|
||||
def test_picker_not_rendered_when_user_has_no_sig(self):
|
||||
# 4b gate wins; picker has no business rendering without a sig.
|
||||
self.user.significator = None
|
||||
self.user.save(update_fields=["significator"])
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertNotContains(response, "my-sea-picker")
|
||||
|
||||
|
||||
class MySeaSpreadFormTemplateTest(TestCase):
|
||||
"""Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
|
||||
spread context + cross's `data-spread-shape` attribute. Iter 3 spec
|
||||
locks `Situation, Action, Outcome` as the default spread (a 3-card
|
||||
variant); the 6 spreads sit under 2 section dividers (3-card / 6-
|
||||
card)."""
|
||||
|
||||
SPREAD_OPTIONS = [
|
||||
("past-present-future", "Past, Present, Future"),
|
||||
("situation-action-outcome", "Situation, Action, Outcome"),
|
||||
("mind-body-spirit", "Mind, Body, Spirit"),
|
||||
("desire-obstacle-solution", "Desire, Obstacle, Solution"),
|
||||
("waite-smith", "Celtic Cross, Waite-Smith"),
|
||||
("escape-velocity", "Celtic Cross, Escape Velocity"),
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="spread@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_default_spread_is_situation_action_outcome(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertEqual(
|
||||
response.context["default_spread"], "situation-action-outcome",
|
||||
)
|
||||
|
||||
def test_context_reversals_pct_defaults_to_25(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertEqual(response.context["reversals_pct"], 25)
|
||||
|
||||
def test_template_renders_all_six_spread_options(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
for value, label in self.SPREAD_OPTIONS:
|
||||
with self.subTest(spread=value):
|
||||
self.assertIn(f'data-value="{value}"', html)
|
||||
self.assertIn(label, html)
|
||||
|
||||
def test_template_renders_three_card_and_six_card_section_dividers(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertEqual(html.count("sea-select-divider"), 2)
|
||||
self.assertIn("3-card spreads", html)
|
||||
self.assertIn("6-card spreads", html)
|
||||
|
||||
def test_template_marks_situation_action_outcome_aria_selected(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
# The default option carries aria-selected="true"; the others false.
|
||||
self.assertIn(
|
||||
'data-value="situation-action-outcome" aria-selected="true"', html,
|
||||
)
|
||||
|
||||
def test_cross_carries_initial_data_spread_sao(self):
|
||||
# `.my-sea-cross[data-spread]` is the per-spread visibility key;
|
||||
# default-spread context value renders into the attribute. SCSS
|
||||
# rules in _gameboard.scss hide the inactive positions per spread.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-spread="situation-action-outcome"')
|
||||
|
||||
def test_template_renders_sao_position_labels_on_default(self):
|
||||
# Server-renders the SAO position labels into the empty drop-zone
|
||||
# `.sea-pos-label` spans so the page is correct before JS boots.
|
||||
# JS swaps labels on spread change.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertIn('data-position="lay">Situation</span>', html)
|
||||
self.assertIn('data-position="cover">Action</span>', html)
|
||||
self.assertIn('data-position="crown">Outcome</span>', html)
|
||||
# Inactive-for-SAO positions render their span but w. empty
|
||||
# textContent (JS fills them on spread switch).
|
||||
self.assertIn('data-position="leave"></span>', html)
|
||||
self.assertIn('data-position="loom"></span>', html)
|
||||
self.assertIn('data-position="cross"></span>', html)
|
||||
|
||||
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertIn("sea-deck-stack--gravity", html)
|
||||
self.assertIn("sea-deck-stack--levity", html)
|
||||
self.assertIn('id="id_sea_lock_hand"', html)
|
||||
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)
|
||||
|
||||
@@ -170,20 +170,109 @@ def toggle_game_kit_sections(request):
|
||||
def my_sea(request):
|
||||
"""Shell view for the My Sea standalone page.
|
||||
|
||||
Sprint 3 scaffolding + Sprint 4b sign-gate. The gate fires when the
|
||||
user has no saved significator — a Look!-formatted Brief-style line
|
||||
nudges them to /billboard/my-sign/ (FYI) or back to /gameboard/
|
||||
(BACK) before the draw UX can be reached. With a sig set, the draw
|
||||
shell renders normally (gatekeeper / sig-select / sea-select land
|
||||
in Sprints 5-9).
|
||||
Branches three ways:
|
||||
|
||||
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b).
|
||||
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w.
|
||||
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps
|
||||
data-phase to picker (the picker UX itself lands in iter 2).
|
||||
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning'
|
||||
Brief banner identical to the one on /billboard/my-sign/ (the user
|
||||
is headed for a draw against the Earthman [Shabby Cardstock]
|
||||
backup deck unless they equip one first).
|
||||
"""
|
||||
user_has_sig = request.user.significator_id is not None
|
||||
no_equipped_deck = request.user.equipped_deck_id is None
|
||||
return render(request, "apps/gameboard/my_sea.html", {
|
||||
"user_has_sig": user_has_sig,
|
||||
"no_equipped_deck": no_equipped_deck,
|
||||
"show_backup_intro_banner": user_has_sig and no_equipped_deck,
|
||||
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the
|
||||
# picker phase. Template guards on `user_has_sig` so a None pass-
|
||||
# through is safe; we pass the FK directly so `.corner_rank` +
|
||||
# `.suit_icon` resolve at render time.
|
||||
"significator": request.user.significator,
|
||||
"significator_reversed": request.user.significator_reversed,
|
||||
# Sprint 5 iter 3 — SPREAD dropdown defaults to Situation/Action/
|
||||
# Outcome (a 3-card spread) per user-locked spec; `reversals_pct`
|
||||
# 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,
|
||||
# Polarity-split full-title overrides — required for Major
|
||||
# Arcana (Earthman trumps 19-21 + cards 48-49) to render
|
||||
# their per-polarity emanation/reversal names on the stage
|
||||
# card. Without these StageCard.populateCard falls back to
|
||||
# the plain `name_title` w. no qualifier. Mirrors the
|
||||
# gameroom `epic.views.sea_deck` JSON shape exactly.
|
||||
"levity_emanation": c.levity_emanation,
|
||||
"gravity_emanation": c.gravity_emanation,
|
||||
"levity_reversal": c.levity_reversal,
|
||||
"gravity_reversal": c.gravity_reversal,
|
||||
"italic_word": c.italic_word,
|
||||
"keywords_upright": c.keywords_upright,
|
||||
"keywords_reversed": c.keywords_reversed,
|
||||
"energies": c.energies,
|
||||
"operations": c.operations,
|
||||
"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
|
||||
|
||||
69
src/functional_tests/sig_page.py
Normal file
69
src/functional_tests/sig_page.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Sig-state FT helpers — sprint 4c of [[project-my-sea-roadmap]]. Two
|
||||
public helpers:
|
||||
|
||||
- `_seed_earthman_sig_pile()` — restore the Earthman DeckVariant + 16
|
||||
MIDDLE court cards that `personal_sig_cards(user)` returns. The
|
||||
migration seed gets flushed by TransactionTestCase between tests
|
||||
(see [[feedback_transactiontestcase_flush]]); each setUp that touches
|
||||
the picker or its dependents must call this before creating a User.
|
||||
|
||||
- `_assign_sig(user, card=None, reversed_flag=False)` — set
|
||||
`User.significator` (+ `significator_reversed`) directly, bypassing
|
||||
the picker UI. Returns the assigned card so downstream assertions can
|
||||
use it. Use this in any FT that needs a "user has a sig" precondition
|
||||
without walking the SCAN SIGN → click thumb → OK → SAVE SIGN flow.
|
||||
|
||||
Naming follows the room_page.py / post_page.py / my_posts_page.py
|
||||
convention: underscored to signal "test infrastructure, not API
|
||||
surface"; public within `functional_tests/`.
|
||||
|
||||
Consumers:
|
||||
test_bill_my_sign — both helpers
|
||||
test_game_my_sea — both helpers (gate / no-gate branches)
|
||||
"""
|
||||
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
|
||||
|
||||
|
||||
def _seed_earthman_sig_pile():
|
||||
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
|
||||
`personal_sig_cards(user)` returns. Idempotent — `get_or_create` on
|
||||
deck + each card slug.
|
||||
|
||||
The 16 cards are Maid/Jack/Queen/King × BRANDS/CROWNS/BLADES/GRAILS.
|
||||
Major 0/1 (Nomad/Schizo) are *not* seeded here — `_filter_major_unlocks`
|
||||
in `personal_sig_cards()` strips them for users without the matching
|
||||
Note unlocks, which is the default state in solo FTs. If a future FT
|
||||
needs the Major seed (Note-unlocked path), it should seed those rows
|
||||
separately or extend this helper w. a flag."""
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
|
||||
for number in (11, 12, 13, 14):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
|
||||
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
||||
"levity_qualifier": "Elevated",
|
||||
"gravity_qualifier": "Graven"},
|
||||
)
|
||||
return earthman
|
||||
|
||||
|
||||
def _assign_sig(user, card=None, reversed_flag=False):
|
||||
"""Assign `user.significator` (and optionally `significator_reversed`)
|
||||
directly, bypassing the picker UI. Returns the assigned card.
|
||||
|
||||
If `card` is None, defaults to the first card in
|
||||
`personal_sig_cards(user)` — the same card the picker happy-path FT
|
||||
targets. Caller is responsible for ensuring the sig pile is seeded
|
||||
(call `_seed_earthman_sig_pile()` before User.create if needed)."""
|
||||
if card is None:
|
||||
card = personal_sig_cards(user)[0]
|
||||
user.significator = card
|
||||
user.significator_reversed = reversed_flag
|
||||
user.save(update_fields=["significator", "significator_reversed"])
|
||||
return card
|
||||
@@ -10,8 +10,9 @@ is branded "Sign" / "Game Sign".
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from .sig_page import _assign_sig, _seed_earthman_sig_pile
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -23,29 +24,6 @@ def _seed_my_sign_applet():
|
||||
)
|
||||
|
||||
|
||||
def _seed_earthman_sig_pile():
|
||||
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
|
||||
personal_sig_cards() returns. TransactionTestCase flushes wipe the
|
||||
migration seed between tests, so each setUp must restore them.
|
||||
See [[feedback_transactiontestcase_flush]]."""
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
|
||||
for number in (11, 12, 13, 14):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
|
||||
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
||||
"levity_qualifier": "Elevated",
|
||||
"gravity_qualifier": "Graven"},
|
||||
)
|
||||
return earthman
|
||||
|
||||
|
||||
class MySignPickerTest(FunctionalTest):
|
||||
"""Happy-path picker: a user with the Earthman deck equipped lands at
|
||||
/billboard/my-sign/, picks a card, clicks SAVE SIGN, and sees the sig
|
||||
@@ -469,13 +447,8 @@ class MySignClearTest(FunctionalTest):
|
||||
_seed_my_sign_applet()
|
||||
self.email = "clear@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
sig_pile = personal_sig_cards(self.gamer)
|
||||
self.target_card = sig_pile[0] if sig_pile else None
|
||||
self.assertIsNotNone(self.target_card)
|
||||
# Pre-save a sig so the CLEAR affordance is visible on landing.
|
||||
self.gamer.significator = self.target_card
|
||||
self.gamer.significator_reversed = False
|
||||
self.gamer.save(update_fields=["significator", "significator_reversed"])
|
||||
# Pre-save a sig so the DEL affordance is visible on landing.
|
||||
self.target_card = _assign_sig(self.gamer)
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is
|
||||
gated behind sig selection — when `user.significator` is None, render a
|
||||
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + BACK
|
||||
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
|
||||
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
|
||||
mirrors the gate hint in its empty-state slot.
|
||||
"""
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from .sig_page import _assign_sig, _seed_earthman_sig_pile
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import DeckVariant, TarotCard, personal_sig_cards
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -32,33 +33,10 @@ def _seed_gameboard_applets():
|
||||
)
|
||||
|
||||
|
||||
def _seed_earthman_sig_pile():
|
||||
"""Re-seed Earthman DeckVariant + the 16 MIDDLE court cards that
|
||||
personal_sig_cards() returns. TransactionTestCase flushes wipe the
|
||||
migration seed between tests, so each setUp must restore them.
|
||||
See [[feedback_transactiontestcase_flush]]."""
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
|
||||
for number in (11, 12, 13, 14):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
|
||||
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
||||
"levity_qualifier": "Elevated",
|
||||
"gravity_qualifier": "Graven"},
|
||||
)
|
||||
return earthman
|
||||
|
||||
|
||||
class MySeaSignGateTest(FunctionalTest):
|
||||
"""Sign-gate UX on the standalone /gameboard/my-sea/ page + the
|
||||
/gameboard/ My Sea applet. User without a saved sig sees a Look!-
|
||||
formatted nudge w. FYI to the picker + BACK to the gameboard."""
|
||||
formatted nudge w. FYI to the picker + NVM to the gameboard."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -77,7 +55,7 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
|
||||
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
|
||||
"""User without significator → /gameboard/my-sea/ shows the Look!-
|
||||
formatted Brief-style line w. the gate copy + FYI + BACK buttons."""
|
||||
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
gate = self.wait_for(
|
||||
@@ -89,11 +67,12 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
self.assertIn("Look!", text)
|
||||
self.assertIn("pick your sign", text.lower())
|
||||
self.assertIn("drawing the Sea", text)
|
||||
# FYI + BACK action buttons
|
||||
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
|
||||
# post-relabel; the BACK→NVM swap was label-only).
|
||||
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
||||
self.assertTrue(fyi.is_displayed())
|
||||
back = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
||||
self.assertTrue(back.is_displayed())
|
||||
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
||||
self.assertTrue(nvm.is_displayed())
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -115,18 +94,19 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gate_back_links_to_gameboard(self):
|
||||
"""BACK button is an `<a href>` pointing at /gameboard/."""
|
||||
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
|
||||
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
back = self.wait_for(
|
||||
nvm = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
|
||||
)
|
||||
)
|
||||
href = back.get_attribute("href") or ""
|
||||
href = nvm.get_attribute("href") or ""
|
||||
self.assertTrue(
|
||||
href.endswith("/gameboard/"),
|
||||
f"BACK should link to /gameboard/, got {href!r}",
|
||||
f"NVM should link to /gameboard/, got {href!r}",
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
@@ -134,8 +114,7 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
||||
"""User w. saved significator → no .my-sea-sign-gate on the page;
|
||||
draw shell renders normally (Sprint 3 placeholder)."""
|
||||
self.gamer.significator = self.target_card
|
||||
self.gamer.save(update_fields=["significator"])
|
||||
_assign_sig(self.gamer, self.target_card)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
self.wait_for(
|
||||
@@ -170,8 +149,7 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
def test_with_sig_applet_renders_default_empty_state(self):
|
||||
"""Applet w. saved sig → no gate, empty-state placeholder (until
|
||||
Sprint 7 wires up the latest-draw rendering)."""
|
||||
self.gamer.significator = self.target_card
|
||||
self.gamer.save(update_fields=["significator"])
|
||||
_assign_sig(self.gamer, self.target_card)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/")
|
||||
self.wait_for(
|
||||
@@ -183,3 +161,837 @@ class MySeaSignGateTest(FunctionalTest):
|
||||
)),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaDrawSeaLandingTest(FunctionalTest):
|
||||
"""Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
|
||||
user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate).
|
||||
|
||||
Landing renders a DRY table hex (parameterized from the room) w. 6
|
||||
chair seats labeled 1C-6C (placeholders for the eventual friend-
|
||||
invite feature per [[project-my-sea-roadmap]] architectural anchor
|
||||
"Six chairs retained even in solo") + a central FREE DRAW `.btn-
|
||||
primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
|
||||
renders w. a red `.fa-ban` status icon (empty slot).
|
||||
|
||||
Click flow: FREE DRAW → seat 1C transitions to `.seated` state
|
||||
(chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
|
||||
circle-check` green) → after a brief delay so the user sees the
|
||||
animation, `data-phase` swaps to `picker` (picker content lands in
|
||||
iter 2). The 'C' = "Chair" (user-locked vocabulary); no role
|
||||
semantics in this solo flow.
|
||||
|
||||
"FREE DRAW" is the label for the 1/24h free quota draw — a future
|
||||
sprint will conditionally swap the label to "DRAW SEA" once the
|
||||
free has been used, w. the DRAW SEA btn calling the room
|
||||
gatekeeper partial for token-deposit.
|
||||
|
||||
The same Brief "Default deck warning" copy from my-sign fires when
|
||||
the user has no equipped deck."""
|
||||
|
||||
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)
|
||||
# Assign a sig so the page passes the Sprint 4b gate + lands on
|
||||
# the new DRAW SEA UX rather than the Look!-line gate.
|
||||
self.target_card = _assign_sig(self.gamer)
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_landing_renders_hex_with_free_draw_btn(self):
|
||||
"""User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
|
||||
used from my-sign / the room shell) w. a central FREE DRAW btn.
|
||||
Element ID `id_draw_sea_btn` describes intent (the draw entry
|
||||
point) — a future sprint will conditionally swap the label to
|
||||
DRAW SEA once the daily free has been used."""
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
# data-phase=landing on the page wrapper
|
||||
page = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']")
|
||||
)
|
||||
# Hex shell present
|
||||
page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex")
|
||||
# FREE DRAW btn in hex center
|
||||
btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
||||
self.assertTrue(btn.is_displayed())
|
||||
self.assertIn("FREE", btn.text.upper())
|
||||
self.assertIn("DRAW", btn.text.upper())
|
||||
self.assertIn("btn-primary", btn.get_attribute("class"))
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
|
||||
"""All 6 chair positions render w. labels 1C-6C (placeholder for
|
||||
friend-invite). CSS class `.table-seat` is preserved so the SCSS
|
||||
positioning rules (data-slot=N) carry over from the room shell.
|
||||
Each seat starts w. a red `.fa-ban` status icon (empty)."""
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seats = self.wait_for(
|
||||
lambda: self._six_seats()
|
||||
)
|
||||
self.assertEqual(len(seats), 6)
|
||||
for n, seat in enumerate(seats, start=1):
|
||||
with self.subTest(slot=n):
|
||||
label = "".join(seat.text.upper().split())
|
||||
self.assertIn(f"{n}C", label)
|
||||
# Each seat carries the red ban status icon initially.
|
||||
seat.find_element(
|
||||
By.CSS_SELECTOR, ".position-status-icon.fa-ban"
|
||||
)
|
||||
|
||||
def _six_seats(self):
|
||||
seats = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat"
|
||||
)
|
||||
if len(seats) != 6:
|
||||
raise AssertionError(f"expected 6 seats, got {len(seats)}")
|
||||
return seats
|
||||
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
|
||||
"""Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
|
||||
swapped for fa-circle-check (visible to the user during the
|
||||
~800ms animation delay); other seats remain empty; then the
|
||||
page's data-phase swaps to 'picker' so iter 2's content can
|
||||
take over. Single-user instance for now → user always gets the
|
||||
lowest-numeral seat (1C)."""
|
||||
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()
|
||||
# Seat 1C goes seated + icon swaps. Other seats unchanged.
|
||||
seat1 = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated"
|
||||
)
|
||||
)
|
||||
seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check")
|
||||
# Seats 2-6 retain the .fa-ban icon (still empty).
|
||||
for n in range(2, 7):
|
||||
with self.subTest(slot=n):
|
||||
other = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".table-seat[data-slot='{n}']"
|
||||
)
|
||||
self.assertNotIn("seated", other.get_attribute("class"))
|
||||
other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban")
|
||||
# After the seat animation, data-phase swaps to picker + landing hides.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
||||
)
|
||||
)
|
||||
landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
|
||||
self.assertFalse(landing.is_displayed())
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_brief_banner_renders_when_no_deck_equipped(self):
|
||||
"""No equipped deck → the same 'Default deck warning' Brief
|
||||
banner from my-sign fires (lifted verbatim). Tagged w. a my-sea-
|
||||
specific class so FTs can disambiguate from any other Briefs."""
|
||||
self.gamer.equipped_deck = None
|
||||
self.gamer.save(update_fields=["equipped_deck"])
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
banner = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-intro-banner"
|
||||
)
|
||||
)
|
||||
self.assertIn("Default deck warning", banner.text)
|
||||
self.assertIn("no deck is equipped", banner.text)
|
||||
self.assertIn("Shabby Cardstock", banner.text)
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_no_brief_banner_when_deck_equipped(self):
|
||||
"""User w. an equipped deck → no Default-deck-warning Brief on
|
||||
landing. Auto-equip via the User post_save signal handles this
|
||||
for fresh users; assertion guards against accidental render of
|
||||
the banner when the condition shouldn't fire."""
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaPickerPhaseTest(FunctionalTest):
|
||||
"""Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after
|
||||
FREE DRAW click swaps `data-phase` to `picker`. Three-card spread:
|
||||
user's saved significator pinned in the center (`.sea-pos-core`) +
|
||||
three drawn-card positions surrounding it — cover (overlaid on sig),
|
||||
leave (left of center), loom (right of center). Crown / lay / cross
|
||||
from the gameroom's 6-position Celtic Cross are deliberately omitted
|
||||
(user-locked spec). Empty drop-zones are visible — actual card-draw
|
||||
wiring lands in iter 3 alongside the form col (spread dropdown /
|
||||
decks / LOCK HAND / DEL)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "picker@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
self.target_card = _assign_sig(self.gamer)
|
||||
|
||||
def _enter_picker_phase(self):
|
||||
"""Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for
|
||||
the page wrapper's data-phase to swap to `picker` (which happens
|
||||
~800ms after click per the seat-1C animation delay in the inline
|
||||
JS)."""
|
||||
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']"
|
||||
)
|
||||
)
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_picker_renders_significator_card_in_core_cell(self):
|
||||
"""User's saved significator pins the `.sea-pos-core` cell — the
|
||||
center of the three-card cross. Card data attribute reflects the
|
||||
actual TarotCard.id so future iters can wire FYI / SPIN onto it."""
|
||||
self._enter_picker_phase()
|
||||
core = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card"
|
||||
)
|
||||
self.assertEqual(
|
||||
core.get_attribute("data-card-id"), str(self.target_card.id)
|
||||
)
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_picker_renders_cover_leave_loom_positions(self):
|
||||
"""The three drawn-card positions (cover/leave/loom) render as
|
||||
empty `.sea-card-slot--empty` drop zones. Cover is overlaid on
|
||||
the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave
|
||||
+ loom sit in their own grid cells flanking core."""
|
||||
picker = self._enter_picker_phase()
|
||||
# Cover lives nested inside .sea-pos-core (overlaid on sig)
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty"
|
||||
)
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty"
|
||||
)
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty"
|
||||
)
|
||||
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_picker_renders_sao_default_position_subset(self):
|
||||
"""Default spread = Situation/Action/Outcome (SAO) → only lay
|
||||
(Situation) + cover (Action) + crown (Outcome) visible from the
|
||||
6 surrounding positions; leave / loom / cross hidden. All 6
|
||||
cells render in DOM so spread-switching never re-mutates the
|
||||
cross structure — per-spread visibility lives in SCSS via
|
||||
`.my-sea-cross[data-spread="..."]` rules."""
|
||||
picker = self._enter_picker_phase()
|
||||
visible = {".sea-pos-lay", ".sea-pos-cover", ".sea-pos-crown"}
|
||||
hidden = {".sea-pos-leave", ".sea-pos-loom", ".sea-pos-cross"}
|
||||
for pos in visible | hidden:
|
||||
with self.subTest(position=pos):
|
||||
elements = picker.find_elements(By.CSS_SELECTOR, pos)
|
||||
self.assertEqual(len(elements), 1, f"{pos} should render in DOM")
|
||||
expected_visible = pos in visible
|
||||
self.assertEqual(
|
||||
elements[0].is_displayed(), expected_visible,
|
||||
f"{pos} visibility wrong for SAO default; expected {expected_visible}",
|
||||
)
|
||||
|
||||
|
||||
class MySeaSpreadFormTest(FunctionalTest):
|
||||
"""Sprint 5 iter 3 — form col on the picker phase: SPREAD dropdown
|
||||
(custom combobox w. 6 options + 2 horizontal section dividers for
|
||||
"3-card spreads" / "6-card spreads"), reversal-rate caption, two
|
||||
DECKS swatches (GRAVITY + LEVITY), LOCK HAND + DEL btns. Selecting
|
||||
a 6-card spread (Celtic Cross variants) swaps `.my-sea-cross[data-
|
||||
spread-shape]` from `three-card` to `six-card`, revealing the
|
||||
crown / lay / cross positions hidden by default.
|
||||
|
||||
Card-draw mechanics — clicking a deck swatch to deposit a card into
|
||||
the next empty slot — defers to iter 4."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "spread@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']"
|
||||
)
|
||||
)
|
||||
|
||||
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_spread_dropdown_renders_six_options_and_two_dividers(self):
|
||||
"""SPREAD combobox has 4 three-card options + 2 six-card
|
||||
options + 2 horizontal section dividers labelled "3-card
|
||||
spreads" / "6-card spreads". Dividers are `role=presentation`
|
||||
+ `.sea-select-divider` so combobox.js skips them.
|
||||
|
||||
The dropdown is closed (`aria-expanded='false'`) on initial
|
||||
render so the <li>s aren't displayed; use textContent rather
|
||||
than `.text` (which returns "" for hidden elements)."""
|
||||
picker = self._enter_picker_phase()
|
||||
options = picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-select-list [role='option']"
|
||||
)
|
||||
self.assertEqual(len(options), 6)
|
||||
option_labels = [
|
||||
o.get_attribute("textContent").strip() for o in options
|
||||
]
|
||||
# Three-card variants — labels per [[project-my-sea-roadmap]]
|
||||
# iter 3 spec lock.
|
||||
self.assertIn("Past, Present, Future", option_labels)
|
||||
self.assertIn("Situation, Action, Outcome", option_labels)
|
||||
self.assertIn("Mind, Body, Spirit", option_labels)
|
||||
self.assertIn("Desire, Obstacle, Solution", option_labels)
|
||||
# Six-card variants
|
||||
self.assertIn("Celtic Cross, Waite-Smith", option_labels)
|
||||
self.assertIn("Celtic Cross, Escape Velocity", option_labels)
|
||||
# Two horizontal dividers
|
||||
dividers = picker.find_elements(By.CSS_SELECTOR, ".sea-select-divider")
|
||||
self.assertEqual(len(dividers), 2)
|
||||
divider_text = "|".join(
|
||||
d.get_attribute("textContent").upper().strip() for d in dividers
|
||||
)
|
||||
self.assertIn("3-CARD SPREADS", divider_text)
|
||||
self.assertIn("6-CARD SPREADS", divider_text)
|
||||
|
||||
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_default_spread_is_situation_action_outcome(self):
|
||||
"""Per the spec, `Situation, Action, Outcome` is the default
|
||||
spread on landing — selected in the combobox + reflected in
|
||||
the hidden `<input id="id_sea_spread">` initial value + on
|
||||
`.my-sea-cross[data-spread]`."""
|
||||
picker = self._enter_picker_phase()
|
||||
hidden = picker.find_element(By.CSS_SELECTOR, "#id_sea_spread")
|
||||
self.assertEqual(
|
||||
hidden.get_attribute("value"), "situation-action-outcome",
|
||||
)
|
||||
selected = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']"
|
||||
)
|
||||
self.assertEqual(
|
||||
selected.get_attribute("textContent").strip(),
|
||||
"Situation, Action, Outcome",
|
||||
)
|
||||
current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
|
||||
self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
|
||||
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
|
||||
self.assertEqual(
|
||||
cross.get_attribute("data-spread"), "situation-action-outcome",
|
||||
)
|
||||
|
||||
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_picking_spread_swaps_data_spread_and_position_visibility(self):
|
||||
"""Each spread reveals its own position subset (user-locked
|
||||
2026-05-19):
|
||||
PPF → leave + cover + loom visible
|
||||
SAO → lay + cover + crown
|
||||
MBS → crown + lay + loom
|
||||
DOS → loom + cross + cover
|
||||
CC variants → all 6 surrounding positions.
|
||||
`.my-sea-cross[data-spread]` swaps on combobox change; SCSS
|
||||
rules toggle the inactive positions to `display: none`."""
|
||||
ALL_POSITIONS = {"crown", "leave", "cover", "cross", "loom", "lay"}
|
||||
SPREAD_POSITIONS = {
|
||||
"past-present-future": {"leave", "cover", "loom"},
|
||||
"situation-action-outcome": {"lay", "cover", "crown"},
|
||||
"mind-body-spirit": {"crown", "lay", "loom"},
|
||||
"desire-obstacle-solution": {"loom", "cross", "crown"},
|
||||
"waite-smith": ALL_POSITIONS,
|
||||
"escape-velocity": ALL_POSITIONS,
|
||||
}
|
||||
picker = self._enter_picker_phase()
|
||||
cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
|
||||
def _pick(value):
|
||||
# Combobox click outside an open dropdown opens it; click on
|
||||
# an option inside selects + closes. Re-opening for each pick
|
||||
# keeps the test deterministic.
|
||||
if combo.get_attribute("aria-expanded") != "true":
|
||||
combo.click()
|
||||
opt = picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
f".sea-select-list [role='option'][data-value='{value}']",
|
||||
)
|
||||
opt.click()
|
||||
|
||||
for value, expected_visible in SPREAD_POSITIONS.items():
|
||||
with self.subTest(spread=value):
|
||||
_pick(value)
|
||||
self.wait_for(
|
||||
lambda v=value: self.assertEqual(
|
||||
cross.get_attribute("data-spread"), v
|
||||
)
|
||||
)
|
||||
for pos in ALL_POSITIONS:
|
||||
element = picker.find_element(
|
||||
By.CSS_SELECTOR, f".sea-pos-{pos}"
|
||||
)
|
||||
should_show = pos in expected_visible
|
||||
self.assertEqual(
|
||||
element.is_displayed(), should_show,
|
||||
f"spread={value} pos={pos}: expected is_displayed={should_show}",
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_per_spread_position_labels_render_and_update(self):
|
||||
"""Each visible empty slot carries a `.sea-pos-label` caption
|
||||
whose text matches the spread's per-position label map (e.g.
|
||||
SAO default: lay='Situation', cover='Action', crown='Outcome').
|
||||
JS updates labels on spread change. Reappropriates the
|
||||
GRAVITY/LEVITY (`.sea-stack-name`) caption styling."""
|
||||
SPREAD_LABELS = {
|
||||
"situation-action-outcome": {"lay": "Situation", "cover": "Action", "crown": "Outcome"},
|
||||
"past-present-future": {"leave": "Past", "cover": "Present", "loom": "Future"},
|
||||
"mind-body-spirit": {"crown": "Mind", "lay": "Body", "loom": "Spirit"},
|
||||
"desire-obstacle-solution": {"loom": "Desire", "cross": "Obstacle","crown":"Solution"},
|
||||
"waite-smith": {"crown": "Crown", "leave": "Beneath", "cover": "Cover",
|
||||
"cross": "Cross", "loom": "Before", "lay": "Behind"},
|
||||
}
|
||||
picker = self._enter_picker_phase()
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
|
||||
def _pick(value):
|
||||
if combo.get_attribute("aria-expanded") != "true":
|
||||
combo.click()
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
f".sea-select-list [role='option'][data-value='{value}']",
|
||||
).click()
|
||||
|
||||
# SAO default — assert labels via the server-rendered initial state.
|
||||
for pos, expected_label in SPREAD_LABELS["situation-action-outcome"].items():
|
||||
with self.subTest(spread="situation-action-outcome", position=pos):
|
||||
label_el = picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
f".sea-pos-label[data-position='{pos}']",
|
||||
)
|
||||
self.assertEqual(
|
||||
label_el.get_attribute("textContent").strip(),
|
||||
expected_label,
|
||||
)
|
||||
|
||||
# Switch to each other spread + verify the labels update.
|
||||
for spread, position_to_label in SPREAD_LABELS.items():
|
||||
if spread == "situation-action-outcome":
|
||||
continue
|
||||
_pick(spread)
|
||||
for pos, expected_label in position_to_label.items():
|
||||
with self.subTest(spread=spread, position=pos):
|
||||
label_el = self.wait_for(
|
||||
lambda p=pos, lbl=expected_label: self._wait_label(p, lbl, picker)
|
||||
)
|
||||
self.assertEqual(
|
||||
label_el.get_attribute("textContent").strip(),
|
||||
expected_label,
|
||||
)
|
||||
|
||||
def _wait_label(self, position, expected_label, picker):
|
||||
el = picker.find_element(
|
||||
By.CSS_SELECTOR, f".sea-pos-label[data-position='{position}']"
|
||||
)
|
||||
if el.get_attribute("textContent").strip() != expected_label:
|
||||
raise AssertionError(
|
||||
f"label@{position}: got "
|
||||
f"{el.get_attribute('textContent')!r}, want {expected_label!r}"
|
||||
)
|
||||
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_open_modal(self, picker, polarity):
|
||||
"""Click a polarity swatch + the FLIP btn that appears → opens
|
||||
the SeaDeal stage modal. Returns the stage element so callers
|
||||
can assert on it before dismissing."""
|
||||
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")
|
||||
)
|
||||
self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
|
||||
flip.click()
|
||||
# SeaDeal.openStage shows #id_sea_stage. Wait for the modal.
|
||||
return self.wait_for(
|
||||
lambda: self._stage_visible()
|
||||
)
|
||||
|
||||
def _stage_visible(self):
|
||||
stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
|
||||
if not stage.is_displayed():
|
||||
raise AssertionError("sea-stage not visible after FLIP click")
|
||||
return stage
|
||||
|
||||
def _dismiss_modal(self):
|
||||
"""Click the stage backdrop → SeaDeal._hideStage → modal hides +
|
||||
slot gains `.--visible` (thumbnail fades in).
|
||||
|
||||
Uses `execute_script` to dispatch the click rather than a native
|
||||
Selenium `.click()` — `.sea-stage-content` overlays the backdrop
|
||||
visually (centered card + stat block), so Selenium reports
|
||||
ElementClickInterceptedException for a direct click. This is
|
||||
the documented Selenium-limitation exception per the TDD skill;
|
||||
the actual backdrop-click → close behaviour is Jasmine-tested
|
||||
in [[SeaDealSpec.js]] / "Backdrop click closes the stage"."""
|
||||
self.browser.execute_script(
|
||||
"document.querySelector('#id_sea_stage .sea-stage-backdrop').click();"
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.assertFalse(
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed()
|
||||
)
|
||||
)
|
||||
|
||||
def _draw_one(self, picker, polarity):
|
||||
"""Full single-draw cycle: open modal + dismiss it. Used by FTs
|
||||
that need to deposit multiple cards in sequence (the stage
|
||||
backdrop blocks subsequent deck-stack clicks)."""
|
||||
self._draw_open_modal(picker, polarity)
|
||||
self._dismiss_modal()
|
||||
|
||||
# ── 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):
|
||||
"""Form col carries the DECKS swatches (GRAVITY + LEVITY), the
|
||||
LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
|
||||
reversal-percentage caption (default 25%)."""
|
||||
picker = self._enter_picker_phase()
|
||||
# DECKS — two stacks
|
||||
stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
|
||||
self.assertEqual(len(stacks), 2)
|
||||
names = "|".join(
|
||||
s.find_element(By.CSS_SELECTOR, ".sea-stack-name").text.upper()
|
||||
for s in stacks
|
||||
)
|
||||
self.assertIn("GRAVITY", names)
|
||||
self.assertIn("LEVITY", names)
|
||||
# LOCK HAND + DEL
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertIn("LOCK", lock.text.upper())
|
||||
self.assertIn("HAND", lock.text.upper())
|
||||
self.assertIn("btn-primary", lock.get_attribute("class"))
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
self.assertIn("DEL", delbtn.text.upper())
|
||||
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
||||
# Reversal % caption — default 25
|
||||
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
||||
self.assertIn("25", hint.text)
|
||||
self.assertIn("reversal", hint.text.lower())
|
||||
|
||||
# ── Test (modal bug fix) ────────────────────────────────────────────────
|
||||
|
||||
def test_flip_click_opens_portaled_stage_modal(self):
|
||||
"""Bug fix (2026-05-19): the user-reported missing modal. After
|
||||
clicking the deck stack + the FLIP btn that appears, SeaDeal.
|
||||
openStage should fire — showing `#id_sea_stage` (position-fixed
|
||||
full-viewport portal) above everything else. Before the fix the
|
||||
slot got filled directly at opacity 0 → 'thumbnail summarily
|
||||
disappears'. Now: modal opens; slot stays at `--filled` but
|
||||
`--visible` is NOT added yet (waits for backdrop dismiss)."""
|
||||
picker = self._enter_picker_phase()
|
||||
stage = self._draw_open_modal(picker, "levity")
|
||||
# Stage card carries the drawn card's data — non-empty corner rank.
|
||||
rank = stage.find_element(
|
||||
By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
|
||||
)
|
||||
self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
|
||||
# Slot in the cross is in `.--filled` state but the thumbnail is
|
||||
# invisible until the modal dismisses (the bug we're guarding).
|
||||
slot = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"sea-card-slot--visible", slot.get_attribute("class"),
|
||||
"slot should still be in pre-reveal opacity-0 state while modal is open",
|
||||
)
|
||||
|
||||
# ── Test (modal bug fix, dismiss reveal) ───────────────────────────────
|
||||
|
||||
def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
|
||||
"""Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
|
||||
modal AND adds `.sea-card-slot--visible` to the deposited slot,
|
||||
making the thumbnail fade in. Confirms the user-reported 'card
|
||||
appears where the slot was' behavior post-dismiss."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_open_modal(picker, "levity")
|
||||
self._dismiss_modal()
|
||||
slot = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
|
||||
)
|
||||
self.assertIn(
|
||||
"sea-card-slot--visible", slot.get_attribute("class"),
|
||||
"post-dismiss, the slot should fade in via `.--visible`",
|
||||
)
|
||||
|
||||
# ── Test (modal bug fix, stat block populates) ─────────────────────────
|
||||
|
||||
def test_modal_stage_renders_stat_block_dom_contract(self):
|
||||
"""SeaDeal._populate populates the stat-block keyword `<ul>`s
|
||||
via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
|
||||
contract — these IDs exist inside the stage — is what this FT
|
||||
pins; the actual stat content (keyword text, qualifier render)
|
||||
is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
|
||||
iter-4a FT pile carry empty keyword arrays so we can't assert
|
||||
text content here without enriching the seed."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_open_modal(picker, "levity")
|
||||
# Stat-block UL elements exist inside the visible stage.
|
||||
upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
|
||||
reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
|
||||
self.assertIsNotNone(upright)
|
||||
self.assertIsNotNone(reversed_ul)
|
||||
# The sea stat block is inside the visible stage modal.
|
||||
stat_block = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
|
||||
)
|
||||
self.assertIsNotNone(stat_block)
|
||||
|
||||
@@ -1324,9 +1324,30 @@ $sea-card-h: 6.5rem;
|
||||
|
||||
.sea-pos-cover { z-index: 3; } // above sig (z-index: 2)
|
||||
.sea-pos-cross { z-index: 4; } // above cover
|
||||
// Empty Cover/Cross slots are invisible — they reveal only once a card is deposited
|
||||
// Empty Cover/Cross slots — subtle dotted outline (no fill) so the
|
||||
// underlying Sig card shows through. Hovering/touching reveals the
|
||||
// full --duoUser mask, opaquing the slot + obscuring the Sig behind.
|
||||
// Border + label dim to 0.25 alpha default; bounce to full on hover.
|
||||
// The filled-slot hover behavior (opacity 0.3/0.15 → 1) at lines 1300-
|
||||
// 1301 is untouched — this only restyles the EMPTY state.
|
||||
.sea-pos-cover .sea-card-slot--empty,
|
||||
.sea-pos-cross .sea-card-slot--empty { opacity: 0; pointer-events: none; }
|
||||
.sea-pos-cross .sea-card-slot--empty {
|
||||
background-color: transparent;
|
||||
border-color: rgba(var(--terUser), 0.25);
|
||||
box-shadow: none;
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
.sea-pos-label { opacity: 0.25; }
|
||||
}
|
||||
|
||||
.sea-pos-cover .sea-card-slot--empty:hover,
|
||||
.sea-pos-cross .sea-card-slot--empty:hover {
|
||||
background-color: rgba(var(--duoUser), 1);
|
||||
border-color: rgba(var(--terUser), 1);
|
||||
|
||||
.sea-pos-label { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.sea-pos-cross .sea-card-slot { transform: rotate(90deg); }
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ body.page-gameboard {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Applet variant — denser layout, omits BACK (the user is already on
|
||||
// Applet variant — denser layout, omits NVM (the user is already on
|
||||
// the gameboard). Smaller line + just the FYI action surviving.
|
||||
&.my-sea-sign-gate--applet {
|
||||
padding: 0.5rem;
|
||||
@@ -205,3 +205,301 @@ body.page-gameboard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── My Sea DRAW SEA landing ─────────────────────────────────────────────────
|
||||
// Sprint 5 iter 1 of [[project-my-sea-roadmap]]. When a user has a saved
|
||||
// significator (gate passed), /gameboard/my-sea/ renders this landing
|
||||
// screen: DRY table hex w. 6 chair seats labeled 1C-6C + central DRAW
|
||||
// SEA btn. Mirrors my-sign's `.my-sign-page` + `.my-sign-landing`
|
||||
// structure — same room-shell chain so room.js's scaleTable() can size
|
||||
// the hex; same flex setup so the container chain propagates real
|
||||
// height down for the scale calc.
|
||||
.my-sea-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.my-sea-landing {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
||||
// FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
|
||||
// font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
|
||||
#id_draw_sea_btn {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// Chair-position labels (1C-6C). Mirrors the room's `.seat-role-
|
||||
// label` grid placement (col 2, row 1 by default; flips to col 1
|
||||
// for left-side seats 3/4/5 so the label sits closest to the hex)
|
||||
// but uses a role-free class name — my-sea is the solo draw flow,
|
||||
// no role-pick phase, so the room's role-grammar doesn't apply.
|
||||
.table-seat .seat-position-label {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(var(--secUser), 1);
|
||||
}
|
||||
.table-seat[data-slot="3"] .seat-position-label,
|
||||
.table-seat[data-slot="4"] .seat-position-label,
|
||||
.table-seat[data-slot="5"] .seat-position-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
// Seated chair (post-FREE DRAW). Visual transition mirrors
|
||||
// `.table-seat.active .fa-chair` from _room.scss line 626 —
|
||||
// --terUser color + --ninUser drop-shadow glow — but uses a stable
|
||||
// `.seated` class (semantically distinct from `.active`: active =
|
||||
// current turn in a multi-user room; seated = draw-locked occupant
|
||||
// in this solo-flow). _room.scss line 596 makes the colour change
|
||||
// a 0.6s ease transition so the chair animates rather than snaps.
|
||||
// Status icon (.position-status-icon) colour swap fa-ban red →
|
||||
// fa-circle-check green is handled by _room.scss lines 615-616.
|
||||
.table-seat.seated .fa-chair {
|
||||
color: rgba(var(--terUser), 1);
|
||||
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Picker phase bg — `--duoUser` matches the table hex's interior so
|
||||
// the landing→picker swap reads as a continuous surface (parallels
|
||||
// `.my-sign-page[data-phase="picker"]` in _card-deck.scss line 704).
|
||||
.my-sea-page[data-phase="picker"] {
|
||||
background: rgba(var(--duoUser), 1);
|
||||
}
|
||||
|
||||
.my-sea-picker {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// .my-sea-cross renders all 6 surrounding positions (crown/leave/lay/
|
||||
// loom + cover/cross overlaid on core) unconditionally. The SPREAD
|
||||
// dropdown sets `data-spread="<name>"` on this element; per-spread
|
||||
// rules below hide the positions each spread doesn't use. Inherits
|
||||
// the 3×3 `grid-template-areas` from _card-deck.scss line 1189-1200
|
||||
// so visible cells land in their canonical positions; hidden cells
|
||||
// just leave their grid slots empty.
|
||||
//
|
||||
// Per-spread position subsets — user-locked 2026-05-19:
|
||||
// PPF: leave (1) cover (2) loom (3) — horizontal middle row
|
||||
// SAO: lay (1) cover (2) crown (3) — vertical center column
|
||||
// MBS: crown (1) lay (2) loom (3) — T-shape (crown + lay vertical, loom right)
|
||||
// DOS: loom (1) cross (2) crown (3) — loom right · cross overlay · crown above
|
||||
// CC variants: all 6 positions (Waite-Smith / Escape Velocity differ in DRAW ORDER only,
|
||||
// not in position visibility).
|
||||
|
||||
// Bump grid gap on my-sea (gameroom .sea-cross stays at 0.5rem since
|
||||
// gameroom slots have no per-position labels). The vertical leave/loom
|
||||
// labels need ~1.5rem of horizontal clearance from adjacent cells, and
|
||||
// the horizontal crown/cover/lay/cross labels need ~1rem of vertical
|
||||
// clearance so they don't overlap into the next row.
|
||||
.my-sea-cross {
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.my-sea-cross[data-spread="past-present-future"] {
|
||||
.sea-pos-crown,
|
||||
.sea-pos-cross,
|
||||
.sea-pos-lay { display: none; }
|
||||
}
|
||||
|
||||
.my-sea-cross[data-spread="situation-action-outcome"] {
|
||||
.sea-pos-leave,
|
||||
.sea-pos-loom,
|
||||
.sea-pos-cross { display: none; }
|
||||
}
|
||||
|
||||
.my-sea-cross[data-spread="mind-body-spirit"] {
|
||||
.sea-pos-leave,
|
||||
.sea-pos-cover,
|
||||
.sea-pos-cross { display: none; }
|
||||
}
|
||||
|
||||
.my-sea-cross[data-spread="desire-obstacle-solution"] {
|
||||
.sea-pos-leave,
|
||||
.sea-pos-cover,
|
||||
.sea-pos-lay { display: none; }
|
||||
}
|
||||
|
||||
// Celtic Cross variants (waite-smith / escape-velocity) — all positions
|
||||
// visible by default. No `display: none` overrides needed.
|
||||
|
||||
// Position-name caption — re-appropriates the GRAVITY/LEVITY
|
||||
// `.sea-stack-name` typographic look (_card-deck.scss line 1557):
|
||||
// small uppercase letter-spaced w. a subtle scaleY stretch,
|
||||
// --terUser ink at 0.6 opacity. No polarity coloring — these are
|
||||
// spread-position labels, not deck identifiers.
|
||||
//
|
||||
// Labels live OUTSIDE the .sea-card-slot (sibling, inside the crucifix
|
||||
// cell or the cover/cross wrapper) so they survive SeaDeal._fillSlot's
|
||||
// `slot.innerHTML = …` clobber on draw. Each label is absolute-
|
||||
// positioned to nearly touch the slot's nearest border per the user-
|
||||
// locked spec:
|
||||
// crown / cover — above top border
|
||||
// lay / cross — below bottom border
|
||||
// leave — left of left border, rotated 90° CCW
|
||||
// loom — right of right border, rotated 90° CW
|
||||
.sea-pos-label {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
color: rgba(var(--seciUser), 1);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Cells need `position: relative` so absolute label children anchor
|
||||
// to them. `.sea-pos-core` already has `position: relative` per the
|
||||
// existing rule in _card-deck.scss line 1311; the other crucifix
|
||||
// cells need it added.
|
||||
.my-sea-cross .sea-crucifix-cell { position: relative; }
|
||||
|
||||
// Above top border — overlaps slot's top edge by 0.1rem (per the
|
||||
// `.sea-stack-name` "tuck under" treatment in _card-deck.scss:1564).
|
||||
.sea-pos-crown > .sea-pos-label,
|
||||
.sea-pos-cover > .sea-pos-label {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0.1rem) scaleY(1.2);
|
||||
}
|
||||
|
||||
// Cover + cross labels dim w. their slots — they sit on top of the
|
||||
// sig card so a vivid label would compete w. the sig at idle. Default
|
||||
// 0.25 opacity matches the slot's faint dotted-outline at idle; the
|
||||
// parent's :hover state (propagated up when the inside `.sea-card-
|
||||
// slot:hover` fires per CSS hover-ancestor rules) boosts to the
|
||||
// `.sea-pos-label` baseline 0.6, matching the slot's `--duoUser` mask
|
||||
// reveal.
|
||||
.sea-pos-cover > .sea-pos-label,
|
||||
.sea-pos-cross > .sea-pos-label {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.sea-pos-cover:hover > .sea-pos-label,
|
||||
.sea-pos-cross:hover > .sea-pos-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Below bottom border — same `0.1rem` overlap but downward.
|
||||
.sea-pos-lay > .sea-pos-label,
|
||||
.sea-pos-cross > .sea-pos-label {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -0.1rem) scaleY(1.2);
|
||||
}
|
||||
|
||||
// Left of left border, rotated 90° CCW — text reads bottom-to-top.
|
||||
// `writing-mode: vertical-rl` puts text top-to-bottom (CW); a 180°
|
||||
// rotation flips it to read bottom-to-top (CCW), satisfying the user-
|
||||
// locked "Leave: counterclockwise" spec.
|
||||
//
|
||||
// `scaleX(1.2)` (instead of the horizontal labels' scaleY) widens the
|
||||
// character column (perpendicular to text-flow) — for vertical-rl
|
||||
// labels, that's the visible "width" the user noticed had been lost
|
||||
// at this angle. Without it, the rotated labels look squat.
|
||||
.sea-pos-leave > .sea-pos-label {
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
writing-mode: vertical-rl;
|
||||
transform: translate(0.1rem, -50%) rotate(180deg) scaleX(1.2);
|
||||
}
|
||||
|
||||
// Right of right border, rotated 90° CW — text reads top-to-bottom.
|
||||
// Native `writing-mode: vertical-rl` direction; no extra rotation.
|
||||
.sea-pos-loom > .sea-pos-label {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
writing-mode: vertical-rl;
|
||||
transform: translate(-0.1rem, -50%) scaleX(1.2);
|
||||
}
|
||||
|
||||
// Section dividers inside the SPREAD combobox — labels "3-card spreads"
|
||||
// / "6-card spreads" separating the option groups. Styled to echo the
|
||||
// `.kit-bag-label` treatment (small uppercase underlined letter-spaced
|
||||
// --quaUser) but horizontal rather than vertical (kit-bag uses writing-
|
||||
// mode: vertical-rl; this is a flat dropdown).
|
||||
.sea-select-list .sea-select-divider {
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
text-decoration: underline;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(var(--quaUser), 0.75);
|
||||
padding: 0.4rem 0.6rem 0.2rem;
|
||||
pointer-events: none; // not selectable; combobox.js skips it
|
||||
// (no role=option), but belt-and-braces
|
||||
// against accidental hover/click styles.
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// Form col on my-sea — same DRY treatment as the gameroom sea-overlay
|
||||
// `.sea-form-col` (handled in _card-deck.scss) but sits next to the
|
||||
// picker's cross on a `--duoUser` page. Just constrain the width so it
|
||||
// doesn't fight the cross for horizontal space.
|
||||
.my-sea-form-col {
|
||||
flex: 0 0 16rem;
|
||||
max-width: 16rem;
|
||||
|
||||
// Portal the SPREAD dropdown out of `.sea-form-main`'s overflow
|
||||
// clip — by default the gameroom's `.sea-form-main { overflow-y:
|
||||
// auto }` (from _card-deck.scss:1424) keeps the modal contents
|
||||
// scrollable, but for my-sea's much shorter form the dropdown gets
|
||||
// clipped instead of overlaying the LOCK HAND / DEL btns below.
|
||||
// Setting overflow visible here lets the absolute-positioned
|
||||
// `.sea-select-list` extend past the form area + sit "above
|
||||
// everything else" via its existing z-index: 100.
|
||||
.sea-form-main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// Bump the dropdown z-index well above the picker's stacking ints
|
||||
// (cover z:3, cross z:4, modal stage z:9999 only opens on draw
|
||||
// anyway). 1000 sits above any in-page layer the user might be
|
||||
// interacting w. when they open the SPREAD picker.
|
||||
.sea-select-list {
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// SPREAD combobox lock — applied after the first deposit so the user
|
||||
// can't switch spread mid-draw + scramble the in-progress hand's
|
||||
// position-to-card mapping. DEL releases the lock by removing this
|
||||
// class. Same `pointer-events: none` treatment as `.btn-disabled` per
|
||||
// [[feedback_btn_disabled_pointer_events]].
|
||||
.sea-select.sea-select--locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -233,25 +233,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body.
|
||||
// palette-* class is on the element so CSS vars cascade from that palette automatically.
|
||||
// Confirmed palette swatch — right-side thumbnail, shares the canonical
|
||||
// gradient w. .swatch via palette-swatch-bg mixin (single source of truth).
|
||||
.note-item__palette {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.15rem solid rgba(var(--secUser), 0.5);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--secUser), 1) 0%,
|
||||
rgba(var(--secUser), 1) 30%,
|
||||
rgba(var(--priUser), 1) 30%,
|
||||
rgba(var(--priUser), 1) 70%,
|
||||
rgba(var(--terUser), 1) 70%,
|
||||
rgba(var(--terUser), 1) 85%,
|
||||
rgba(var(--quaUser), 1) 85%,
|
||||
rgba(var(--quaUser), 1) 100%
|
||||
);
|
||||
@include palette-swatch-bg;
|
||||
}
|
||||
|
||||
// ── Palette modal ──────────────────────────────────────────────────────────
|
||||
@@ -282,20 +271,7 @@
|
||||
height: 3.25rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
// Gradient uses vars scoped to the parent palette-* class,
|
||||
// so each swatch shows its own palette's colours (same as .swatch).
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--secUser), 1) 0%,
|
||||
rgba(var(--secUser), 1) 30%,
|
||||
rgba(var(--priUser), 1) 30%,
|
||||
rgba(var(--priUser), 1) 70%,
|
||||
rgba(var(--terUser), 1) 70%,
|
||||
rgba(var(--terUser), 1) 85%,
|
||||
rgba(var(--quaUser), 1) 85%,
|
||||
rgba(var(--quaUser), 1) 100%
|
||||
);
|
||||
border: 0.15rem solid rgba(var(--secUser), 0.5);
|
||||
@include palette-swatch-bg;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
// Canonical palette swatch gradient. Single source of truth for every place
|
||||
// a palette is rendered as a 4-band swatch (applet thumb, billnote thumb,
|
||||
// billnote modal options). The palette-* class on the element supplies the
|
||||
// --priUser/--secUser/--terUser/--quiUser vars; this mixin paints them.
|
||||
@mixin palette-swatch-bg {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--secUser), 1) 0%,
|
||||
@@ -37,6 +35,16 @@
|
||||
rgba(var(--quiUser), 1) 100%
|
||||
);
|
||||
border: 0.15rem solid rgba(var(--secUser), 0.5);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
@include palette-swatch-bg;
|
||||
|
||||
&.active {
|
||||
border: 0.2rem solid rgba(var(--ninUser), 1);
|
||||
|
||||
@@ -139,50 +139,9 @@
|
||||
</div>{# /.sea-modal-wrap #}
|
||||
|
||||
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
|
||||
<div class="sea-stage" id="id_sea_stage" style="display:none">
|
||||
<div class="sea-stage-backdrop"></div>
|
||||
<div class="sea-stage-content">
|
||||
<div class="sig-stage-card sea-stage-card">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<p class="fan-card-name"></p>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
|
||||
{# swaps the class names on these <p>s for Major arcana so each #}
|
||||
{# element's class still matches its semantic content. #}
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info fyi-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# Extracted to a shared partial so the my-sea picker (Sprint 5 iter 4-bugs) #}
|
||||
{# reuses the same DOM contract that SeaDeal binds to. #}
|
||||
{% include "apps/gameboard/_partials/_sea_stage.html" %}
|
||||
|
||||
</div>{# /.sea-overlay #}
|
||||
|
||||
|
||||
52
src/templates/apps/gameboard/_partials/_sea_stage.html
Normal file
52
src/templates/apps/gameboard/_partials/_sea_stage.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{# Sea stage — full-viewport portaled modal (`position: fixed; inset: 0` #}
|
||||
{# per _card-deck.scss:1615) that opens above the picker / overlay when #}
|
||||
{# `SeaDeal.openStage(card, posSelector, isLevity)` fires. Hosts the #}
|
||||
{# full card face + stat block + SPIN / FYI controls; click backdrop to #}
|
||||
{# dismiss + reveal the deposited card thumbnail in its slot. #}
|
||||
{# #}
|
||||
{# Shared by the gameroom SEA SELECT phase and the my-sea picker — same #}
|
||||
{# HTML, same SeaDeal module bindings; only the parent overlay differs. #}
|
||||
<div class="sea-stage" id="id_sea_stage" style="display:none">
|
||||
<div class="sea-stage-backdrop"></div>
|
||||
<div class="sea-stage-content">
|
||||
<div class="sig-stage-card sea-stage-card">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<p class="fan-card-name"></p>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
{# Default DOM order — matches non-major arcana layout. stage-card.js #}
|
||||
{# swaps the class names on these <p>s for Major arcana so each #}
|
||||
{# element's class still matches its semantic content. #}
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info fyi-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,11 +5,11 @@
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="my-sea-page">
|
||||
<div class="my-sea-page" data-phase="landing">
|
||||
{% 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. #}
|
||||
{# FYI (→ /billboard/my-sign/) + BACK (→ /gameboard/) until the #}
|
||||
{# FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) until the #}
|
||||
{# user picks a sign. Inline (not portaled like .note-banner) #}
|
||||
{# because the gate IS the page content, not a transient nudge. #}
|
||||
<div class="my-sea-sign-gate">
|
||||
@@ -18,15 +18,559 @@
|
||||
</p>
|
||||
<div class="my-sea-sign-gate__actions">
|
||||
<a class="btn btn-cancel my-sea-sign-gate__back"
|
||||
href="{% url 'gameboard' %}">BACK</a>
|
||||
href="{% url 'gameboard' %}">NVM</a>
|
||||
<a class="btn btn-info my-sea-sign-gate__fyi"
|
||||
href="{% url 'billboard:my_sign' %}">FYI</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Sprint 3 shell — gatekeeper / sig-select / sea-select phases #}
|
||||
{# will land here in later sprints of the My Sea roadmap. #}
|
||||
<p class="my-sea-page__empty">No draws yet—the depths remain unfathomable.</p>
|
||||
{# 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/. #}
|
||||
<div class="my-sea-landing">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
<div class="room-table-scene">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for n in "123456" %}
|
||||
{# Chair-position labels (1C-6C). No roles in #}
|
||||
{# my-sea (this is the solo draw flow); using #}
|
||||
{# `.seat-position-label` instead of the room's #}
|
||||
{# `.seat-role-label` to keep the no-role #}
|
||||
{# semantics clean. `.position-status-icon` + #}
|
||||
{# `.fa-ban` are unchanged — already role- #}
|
||||
{# agnostic in _room.scss. #}
|
||||
<div class="table-seat" data-slot="{{ n }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ n }}C</span>
|
||||
<i class="position-status-icon fa-solid fa-ban"></i>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Picker phase — per-spread flexible layout. Sig pins .sea- #}
|
||||
{# pos-core; the 6 surrounding positions all render in DOM #}
|
||||
{# so the SPREAD dropdown can swap `.my-sea-cross[data- #}
|
||||
{# spread]` between the 4 three-card variants (each w. its #}
|
||||
{# own 3-position subset + draw order) + the 2 six-card #}
|
||||
{# Celtic Cross variants (all 6 surrounding positions). #}
|
||||
{# Each empty slot carries a `.sea-pos-label` caption (re- #}
|
||||
{# appropriated from the GRAVITY/LEVITY .sea-stack-name look) #}
|
||||
{# that JS updates per spread. #}
|
||||
{# #}
|
||||
{# `id="id_sea_overlay"` aliases the picker to what SeaDeal #}
|
||||
{# binds to (the gameroom uses the same ID on a different #}
|
||||
{# page — no DOM collision since my-sea + gameroom never co- #}
|
||||
{# 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="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 #}
|
||||
{# `slot.innerHTML = …` (which writes the drawn card's corner- #}
|
||||
{# rank + suit-icon) doesn't clobber it. Labels persist as #}
|
||||
{# adjacent siblings + are positioned via absolute SCSS to #}
|
||||
{# 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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="sea-crucifix-cell sea-pos-core">
|
||||
<div class="sig-stage-card sea-sig-card"
|
||||
data-card-id="{{ significator.id }}">
|
||||
<span class="fan-corner-rank">{{ significator.corner_rank }}</span>
|
||||
{% if significator.suit_icon %}<i class="fa-solid {{ significator.suit_icon }}"></i>{% endif %}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Form col — SPREAD combobox + DECKS swatches + LOCK #}
|
||||
{# HAND / DEL. DRY w. gameroom `_sea_overlay.html`'s #}
|
||||
{# `.sea-form-col` shape; my-sea-specific differences: #}
|
||||
{# (a) 6 spread options under 2 section dividers, #}
|
||||
{# (b) default = situation-action-outcome (3-card), #}
|
||||
{# (c) no `.sea-modal-header` (the gateway IS the page). #}
|
||||
<div class="sea-form-col my-sea-form-col">
|
||||
<div class="sea-form-main">
|
||||
<div class="sea-field">
|
||||
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
|
||||
<p class="sea-reversal-hint">{{ reversals_pct|default:25 }}% reversals</p>
|
||||
{# autocomplete="off" opts the hidden input out of #}
|
||||
{# Firefox's form-history autofill, which otherwise #}
|
||||
{# restores the LAST value on soft reload (F5). #}
|
||||
{# Without this, combobox.js's `select(i)` short- #}
|
||||
{# circuits its change-event dispatch when the #}
|
||||
{# user re-picks the value Firefox already restored #}
|
||||
{# → my-sea's sync() never fires → data-spread on #}
|
||||
{# .my-sea-cross stays stuck on SAO default. #}
|
||||
<input type="hidden" id="id_sea_spread" name="spread"
|
||||
value="{{ default_spread }}" autocomplete="off">
|
||||
<div class="sea-select"
|
||||
data-combobox
|
||||
data-combobox-target="id_sea_spread"
|
||||
role="combobox"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby="id_sea_spread_label"
|
||||
tabindex="0">
|
||||
<span class="sea-select-current">Situation, Action, Outcome</span>
|
||||
<span class="sea-select-arrow" aria-hidden="true">▾</span>
|
||||
<ul class="sea-select-list" role="listbox">
|
||||
<li role="presentation" class="sea-select-divider">3-card spreads</li>
|
||||
<li role="option" data-value="past-present-future" aria-selected="false">Past, Present, Future</li>
|
||||
<li role="option" data-value="situation-action-outcome" aria-selected="true">Situation, Action, Outcome</li>
|
||||
<li role="option" data-value="mind-body-spirit" aria-selected="false">Mind, Body, Spirit</li>
|
||||
<li role="option" data-value="desire-obstacle-solution" aria-selected="false">Desire, Obstacle, Solution</li>
|
||||
<li role="presentation" class="sea-select-divider">6-card spreads</li>
|
||||
<li role="option" data-value="waite-smith" aria-selected="false">Celtic Cross, Waite-Smith</li>
|
||||
<li role="option" data-value="escape-velocity" aria-selected="false">Celtic Cross, Escape Velocity</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sea-stacks">
|
||||
<span class="sea-stacks-label">DECKS</span>
|
||||
<div class="sea-deck-stack sea-deck-stack--gravity">
|
||||
<div class="sea-stack-face">
|
||||
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
||||
</div>
|
||||
<span class="sea-stack-name">Gravity</span>
|
||||
</div>
|
||||
<div class="sea-deck-stack sea-deck-stack--levity">
|
||||
<div class="sea-stack-face">
|
||||
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
||||
</div>
|
||||
<span class="sea-stack-name">Levity</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
DEL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{# Sea stage — portaled modal that opens on FLIP click via #}
|
||||
{# SeaDeal.openStage. `position:fixed; inset:0` covers the #}
|
||||
{# viewport; click backdrop to dismiss + reveal the slot #}
|
||||
{# thumbnail. #}
|
||||
{% include "apps/gameboard/_partials/_sea_stage.html" %}
|
||||
</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" }}
|
||||
{# StageCard + SeaDeal — both bind to `#id_sea_overlay` (the #}
|
||||
{# my-sea-picker) + `#id_sea_stage` (the stage partial) on #}
|
||||
{# DOMContentLoaded; openStage() runs on FLIP click below. #}
|
||||
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/combobox.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// Per-spread draw order + position labels — locked in spec
|
||||
// (user 2026-05-19). Each three-card spread uses a DIFFERENT
|
||||
// 3-position subset of the 6 surrounding positions, in a
|
||||
// specific order. The Celtic Cross variants share position
|
||||
// labels (Crown/Beneath/Cover/Cross/Before/Behind — gameroom
|
||||
// vocabulary) but differ in draw order.
|
||||
var DRAW_ORDER = {
|
||||
'past-present-future': ['leave', 'cover', 'loom'],
|
||||
'situation-action-outcome': ['lay', 'cover', 'crown'],
|
||||
'mind-body-spirit': ['crown', 'lay', 'loom'],
|
||||
'desire-obstacle-solution': ['loom', 'cross', 'crown'],
|
||||
'waite-smith': ['cover', 'cross', 'crown', 'lay', 'loom', 'leave'],
|
||||
'escape-velocity': ['cover', 'cross', 'lay', 'leave', 'crown', 'loom'],
|
||||
};
|
||||
var POSITION_LABELS = {
|
||||
'past-present-future': { leave: 'Past', cover: 'Present', loom: 'Future' },
|
||||
'situation-action-outcome': { lay: 'Situation', cover: 'Action', crown: 'Outcome' },
|
||||
'mind-body-spirit': { crown: 'Mind', lay: 'Body', loom: 'Spirit' },
|
||||
'desire-obstacle-solution': { loom: 'Desire', cross: 'Obstacle',crown: 'Solution' },
|
||||
'waite-smith': { crown: 'Crown', leave: 'Beneath', cover: 'Cover', cross: 'Cross', loom: 'Before', lay: 'Behind' },
|
||||
// Escape Velocity remaps the diagonal positions per the
|
||||
// user-locked spec (2026-05-19): Beneath→Lay, Before→
|
||||
// Loom, Behind→Leave. Crown/Cover/Cross keep the WS
|
||||
// names.
|
||||
'escape-velocity': { crown: 'Crown', leave: 'Lay', cover: 'Cover', cross: 'Cross', loom: 'Loom', lay: 'Leave' },
|
||||
};
|
||||
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 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"]');
|
||||
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. `.sea-pos-label` is now a SIBLING of the slot
|
||||
// (outside it) so SeaDeal's innerHTML clobber on draw
|
||||
// doesn't touch it — we don't need to re-render the
|
||||
// label here, just wipe slot contents + classes.
|
||||
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;
|
||||
slot.innerHTML = '';
|
||||
}
|
||||
|
||||
function _lockSpread() {
|
||||
// Lock the SPREAD combobox after the first deposit —
|
||||
// switching spread mid-draw would scramble the in-
|
||||
// progress hand. Unlocks on DEL.
|
||||
if (seaSelect) {
|
||||
seaSelect.classList.add('sea-select--locked');
|
||||
seaSelect.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
}
|
||||
function _unlockSpread() {
|
||||
if (seaSelect) {
|
||||
seaSelect.classList.remove('sea-select--locked');
|
||||
seaSelect.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function _reshuffleDeck() {
|
||||
// Fisher-Yates re-shuffle on DEL — re-distributes cards
|
||||
// across both polarity halves + re-rolls the 25% reversal
|
||||
// axis per card. Page-load shuffle already came from the
|
||||
// server (`_my_sea_deck_data`); subsequent shuffles run
|
||||
// client-side so DEL → fresh-hand doesn't require a
|
||||
// network round-trip.
|
||||
var all = (_deckData.levity || []).concat(_deckData.gravity || []);
|
||||
for (var i = all.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = all[i]; all[i] = all[j]; all[j] = tmp;
|
||||
}
|
||||
// Clone each card (don't mutate the immutable server payload
|
||||
// — DEL can fire many times; we don't want successive shuffles
|
||||
// to fold previous reversal flips into the next round).
|
||||
all = all.map(function (c) {
|
||||
var clone = {};
|
||||
for (var k in c) if (Object.prototype.hasOwnProperty.call(c, k)) clone[k] = c[k];
|
||||
clone.reversed = Math.random() < 0.25;
|
||||
return clone;
|
||||
});
|
||||
var mid = Math.floor(all.length / 2);
|
||||
_levityPile = all.slice(0, mid);
|
||||
_gravityPile = all.slice(mid);
|
||||
}
|
||||
|
||||
function _resetHand() {
|
||||
_filled = 0;
|
||||
_hideOk();
|
||||
_unlockSpread();
|
||||
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);
|
||||
_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) {
|
||||
_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) {
|
||||
// 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);
|
||||
}
|
||||
_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) {
|
||||
var pos = el.dataset.position;
|
||||
el.textContent = labels[pos] || '';
|
||||
});
|
||||
}
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// Belt-and-braces autofill defense (paired w. autocomplete=
|
||||
// off on the hidden input above). Firefox occasionally
|
||||
// restores form-history values on soft reload even on
|
||||
// hidden inputs; if it does, hidden.value diverges from
|
||||
// the server-rendered aria-selected option + combobox.js
|
||||
// short-circuits its change-event dispatch on subsequent
|
||||
// option picks of the autofilled value. Force-sync from
|
||||
// the server-rendered aria-selected source-of-truth.
|
||||
var _initialOpt = document.querySelector(
|
||||
'.sea-select-list [role="option"][aria-selected="true"]'
|
||||
);
|
||||
if (_initialOpt && hidden.value !== _initialOpt.dataset.value) {
|
||||
hidden.value = _initialOpt.dataset.value;
|
||||
}
|
||||
|
||||
// Exposed for iter 4b / future surfaces.
|
||||
window._mySeaDrawOrder = DRAW_ORDER;
|
||||
}());
|
||||
</script>
|
||||
|
||||
<script src="{% static 'apps/epic/room.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var page = document.querySelector('.my-sea-page');
|
||||
if (!page) return;
|
||||
var landing = page.querySelector('.my-sea-landing');
|
||||
var picker = page.querySelector('.my-sea-picker');
|
||||
var drawBtn = document.getElementById('id_draw_sea_btn');
|
||||
// FREE DRAW click flow:
|
||||
// 1) seat 1C transitions to .seated (chair --terUser +
|
||||
// drop-shadow glow + .fa-ban → .fa-circle-check —
|
||||
// _room.scss line 596 makes the colour change a
|
||||
// 0.6s ease transition);
|
||||
// 2) after a brief delay (so the user sees the seat
|
||||
// animation), data-phase swaps to 'picker' + the
|
||||
// landing hides. Picker content lands in iter 2.
|
||||
// The seat-take logic is solo-coded for now: 1C is the
|
||||
// lowest-numeral chair, and my-sea is 1-user-per-page
|
||||
// until the friend-invite feature (per [[project-my-
|
||||
// sea-roadmap]]) — so 1C is always the user's seat.
|
||||
var SEAT_ANIM_MS = 800;
|
||||
if (drawBtn) {
|
||||
drawBtn.addEventListener('click', function () {
|
||||
var seat1 = page.querySelector('.table-seat[data-slot="1"]');
|
||||
if (seat1) {
|
||||
seat1.classList.add('seated');
|
||||
var statusIcon = seat1.querySelector('.position-status-icon');
|
||||
if (statusIcon) {
|
||||
statusIcon.classList.remove('fa-ban');
|
||||
statusIcon.classList.add('fa-circle-check');
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
page.setAttribute('data-phase', 'picker');
|
||||
if (landing) landing.style.display = 'none';
|
||||
if (picker) picker.style.display = '';
|
||||
}, SEAT_ANIM_MS);
|
||||
});
|
||||
}
|
||||
// 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
|
||||
// dispatch a resize once layout settles.
|
||||
window.requestAnimationFrame(function () {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{# Brief 'Default deck warning' banner — lifted verbatim from #}
|
||||
{# /billboard/my-sign/'s no-equipped-deck path. Same copy, #}
|
||||
{# same FYI (→ /gameboard/) + NVM (dismiss + proceed) actions.#}
|
||||
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
|
||||
{# any other Briefs on the page. #}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
{% if show_backup_intro_banner %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!window.Brief || !Brief.showBanner) return;
|
||||
Brief.showBanner({
|
||||
title: 'Default deck warning',
|
||||
line_text: 'Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.',
|
||||
post_url: '{% url "gameboard" %}',
|
||||
created_at: '',
|
||||
kind: 'NUDGE',
|
||||
});
|
||||
var banner = document.querySelector('.note-banner');
|
||||
if (banner) banner.classList.add('my-sea-intro-banner');
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user