SEED MAP felt: 2D d3-delaunay Voronoi/Delaunay dual graph (roadmap step 21, Step 1) — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

New inline --duoUser felt (sibling of CAST SKY / DRAW SEA) that opens on
#id_seed_map_btn once the 6-card sea hand completes (hand_complete); paints a
Voronoi cell layer (territory) + a Delaunay edge layer (adjacency) from
PLACEHOLDER seeds. Card-driven seeding (the 6 Celtic-Cross cards) is Step 2.

- voronoi-map.js: window.SeedMap.draw/drawPlaceholder/clear over the bundled
  d3.min.js (d3-delaunay ships in the v7.9.0 UMD bundle — no new dep); a
  ResizeObserver re-tessellates to fill the felt on resize; data-seed reads d3's
  ring.index (survives skipped/degenerate cells in Step 2)
- _seed_map_overlay.html felt + room.html include + has-seed-stage (gated on hand_complete)
- three-way felt close (T3): openSeed closes sky+sea; openSky/openSea reciprocally close seed
- .room-menu-seed gear NVM pane + room-views.js seed-open branch
- _sky.scss felt block (T1: no aperture-fill light; T2: chained selector; fills the pane) + _room.scss aperture pin
- VoronoiMapSpec Jasmine + game_room_seed_map FT + 5 felt ITs; CarteTray NVM count 2->3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-09 21:02:21 -04:00
parent 02c4307a95
commit cde556b178
15 changed files with 643 additions and 4 deletions

View File

@@ -0,0 +1,119 @@
"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21, Step 1.
The SEED MAP felt is the inline --duoUser sibling of the CAST SKY / DRAW SEA
felts: once the seat's Celtic-Cross hand is complete (hand_complete), SEED MAP
is the live hex phase btn, and clicking it opens a felt that paints a 2D
d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge
layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
so we assert only that the dual graph renders both layers.
Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side.
We seed a CONFIRMED Character with a complete hand directly in the DB, so there
is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
"""
from django.urls import reverse
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.epic.models import Character, DeckVariant, TarotCard
from apps.lyric.models import User
from .base import FunctionalTest
from .test_game_room_select_sea import _make_sky_confirmed_room
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
class SeedMapFeltTest(FunctionalTest):
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200) # portrait — clicking hex-area btns
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True,
"is_polarized": True, "has_card_images": False},
)
# One card is enough: hand_complete is purely len(hand) >= 6, and STEP-1
# rendering uses PLACEHOLDER seeds, so card identity is irrelevant. Seed
# it explicitly — TransactionTestCase flushes migration cards (no
# serialized_rollback). [[feedback-transactiontestcase-flush]]
self.card, _ = TarotCard.objects.get_or_create(
deck_variant=self.earthman, slug="seed-fixture-em",
defaults={"arcana": "MAJOR", "suit": None, "number": 0, "name": "Fixture"},
)
self.gamer, _ = User.objects.get_or_create(email="founder@test.io")
self.gamer.unlocked_decks.add(self.earthman)
self.gamer.equipped_deck = self.earthman
self.gamer.save(update_fields=["equipped_deck"])
def _seed_room(self, hand_len=6):
"""SKY_SELECT room, founder seated PC, with a CONFIRMED Character whose
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
=> SEED MAP is the live phase btn + the felt renders."""
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman)
hand = [
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
for p in _HAND_POSITIONS[:hand_len]
]
Character.objects.create(
seat=seat, significator=seat.significator or self.card,
chart_data={"planets": {}}, confirmed_at=timezone.now(),
celtic_cross={"spread": "waite-smith", "hand": hand},
)
return room
def _room_url(self, room):
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})
def test_seed_map_felt_opens_and_paints_dual_graph(self):
room = self._seed_room(hand_len=6)
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
# hand_complete → SEED MAP is the live phase btn (loses hex-phase-btn--out).
self.wait_for(lambda: self.assertNotIn(
"hex-phase-btn--out",
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
))
# Click opens the felt. JS-click + retry absorbs the parse-time bind race
# (mirrors _click_and_assert_sea_open in test_game_room_select_sea.py).
def _click_and_assert_open():
btn = self.browser.find_element(By.ID, "id_seed_map_btn")
self.browser.execute_script("arguments[0].click()", btn)
self.assertTrue(self.browser.execute_script(
"return document.documentElement.classList.contains('seed-open')"
))
self.wait_for(_click_and_assert_open)
self.assertTrue(
self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed()
)
# The d3-delaunay dual graph paints BOTH layers from placeholder seeds:
# Voronoi cells (territory) + Delaunay edges (adjacency).
self.wait_for(lambda: self.assertGreaterEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length"
), 1,
))
self.assertGreaterEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_map_svg .delaunay-edge').length"
), 1,
)
def test_seed_map_felt_absent_until_hand_complete(self):
room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
self.wait_for(lambda: self.browser.find_element(By.ID, "id_seed_map_btn"))
# SEED MAP stays --out and the felt is never injected into the DOM.
self.assertIn(
"hex-phase-btn--out",
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
)
self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])