diff --git a/src/apps/epic/static/apps/epic/room-views.js b/src/apps/epic/static/apps/epic/room-views.js index 94f76dc..700d869 100644 --- a/src/apps/epic/static/apps/epic/room-views.js +++ b/src/apps/epic/static/apps/epic/room-views.js @@ -91,6 +91,7 @@ var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas'); var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky'); var paneSea = roomMenu && roomMenu.querySelector('.room-menu-sea'); + var paneSeed = roomMenu && roomMenu.querySelector('.room-menu-seed'); var GEARLESS = { yarn: true, post: true, pulse: true }; var onReelhouse = false; @@ -102,6 +103,7 @@ if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none'; if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none'; if (paneSea) paneSea.style.display = pane === paneSea ? '' : 'none'; + if (paneSeed) paneSeed.style.display = pane === paneSeed ? '' : 'none'; } function closeRoomMenu() { if (roomMenu) roomMenu.style.display = 'none'; @@ -125,6 +127,13 @@ showPane(paneSea); return; } + // SEED MAP felt open → the gear is the seed NVM pane (returns to the + // hex), same as the sky/sea felts. + if (document.documentElement.classList.contains('seed-open')) { + gearBtn.classList.remove('gear-disabled'); + showPane(paneSeed); + return; + } var disabled = onReelhouse && GEARLESS[current]; gearBtn.classList.toggle('gear-disabled', !!disabled); if (disabled) { closeRoomMenu(); return; } diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 27c145d..4c534ee 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1018,11 +1018,12 @@ class CarteTrayFollowsSelectedSeatTest(TestCase): self.room.save() response = self.client.get(self.room_url + "?seat=4") self.assertEqual(response.context["current_slot"], 4) - # The room-menu-sky + room-menu-sea panes both return to the acting seat. + # room-menu-sky + room-menu-sea + room-menu-seed all return to the acting + # seat (the SEED MAP felt's NVM joined the felt panes — roadmap step 21). self.assertContains( response, f'href="{self.room_url}?seat=4" class="btn btn-cancel">NVM', - count=2, + count=3, ) def test_sky_sea_nvm_default_targets_lowest_owned(self): @@ -1035,7 +1036,7 @@ class CarteTrayFollowsSelectedSeatTest(TestCase): self.assertContains( response, f'href="{self.room_url}?seat=1" class="btn btn-cancel">NVM', - count=2, + count=3, ) @@ -4581,6 +4582,58 @@ class PickSeaUnifiedFeltTest(TestCase): [sea] = parsed.cssselect("#id_pick_sea_btn") self.assertIn("hex-phase-btn--out", sea.get("class", "")) + # ── SEED MAP felt (Voronoi map, roadmap step 21, Step 1) ────────────────── + def _complete_hand(self): + """Place a 6-card hand on the confirmed Character so hand_complete is true + (one card_id reused — the gate is purely len(hand) >= 6).""" + card_id = TarotCard.objects.filter(deck_variant=self.earthman).first().id + char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False) + char.celtic_cross = {"spread": "waite-smith", "hand": [ + {"position": p, "card_id": card_id, "reversed": False, "polarity": "gravity"} + for p in ["cover", "cross", "crown", "lay", "loom", "leave"]]} + char.save(update_fields=["celtic_cross"]) + + def test_seed_map_felt_renders_when_hand_complete(self): + """Once hand_complete, room.html injects the SEED MAP felt — the inline + --duoUser sibling of the sky/sea felts — carrying the d3-delaunay dual-graph + svg + its renderer include (Step 1: bare integration, placeholder seeds).""" + self._complete_hand() + content = self.client.get(self.url).content.decode() + self.assertIn('id="id_seed_map_overlay"', content) + self.assertIn("seed-page--room", content) + self.assertIn('id="id_seed_map_svg"', content) + self.assertIn("voronoi-map", content) + # Renderer + d3 (d3-delaunay ships inside the d3.min.js bundle). + self.assertIn("apps/gameboard/voronoi-map.js", content) + self.assertIn("apps/gameboard/d3.min.js", content) + # Open/close API exposed for the burger reopen + cross-felt swap. + self.assertIn("openSeedMapFelt", content) + self.assertIn("closeSeedMapFelt", content) + + def test_seed_map_felt_absent_until_hand_complete(self): + """Pre-completion the felt is NOT injected — the include is gated on + hand_complete, same as the SEED MAP btn's live-state.""" + content = self.client.get(self.url).content.decode() + self.assertNotIn('id="id_seed_map_overlay"', content) + + def test_room_gear_has_seed_nvm_pane(self): + """The gear carries a SEED NVM pane (sibling of room-menu-sky/-sea), hidden + by default and revealed by room-views.js on html.seed-open. Like its + siblings it renders for the whole table phase (gated on scroll_filter, NOT + hand_complete — the FELT is what's hand_complete-gated).""" + content = self.client.get(self.url).content.decode() + self.assertIn('class="room-menu-seed" style="display:none"', content) + + def test_sky_and_sea_opens_close_a_live_seed_felt(self): + """T3 (three-way): the SEED MAP felt is a third equal-z sibling, so + openSky()/openSea() must each ALSO full-close it. window.closeSeedMapFelt + is wired from the seed overlay (definition) AND the sky + sea overlays (the + reciprocal closes) → >= 3 references on the page. + [[feedback-equal-z-felt-siblings-double-open]]""" + self._complete_hand() + content = self.client.get(self.url).content.decode() + self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3) + def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self): """Symmetric with Sky Select (which leaves the sea btn lit while the sky felt is up): opening the Sea Select felt must NOT grey the burger Sky diff --git a/src/apps/gameboard/static/apps/gameboard/voronoi-map.js b/src/apps/gameboard/static/apps/gameboard/voronoi-map.js new file mode 100644 index 0000000..8fec3c9 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/voronoi-map.js @@ -0,0 +1,121 @@ +/* voronoi-map.js — SEED MAP dual-graph renderer (Voronoi map, roadmap step 21). + * + * Paints a 2D d3-delaunay DUAL GRAPH into an inline : a Voronoi CELL layer + * (the "territory" base) + a Delaunay EDGE layer (the "adjacency/movement" + * overlay). STEP 1 uses PLACEHOLDER seeds; card-driven seeding (the 6 Celtic- + * Cross cards -> Deleuzian territoriality) is STEP 2. See project_voronoi_spec. + * + * Requires D3 v7 (with d3-delaunay, bundled in d3.min.js) as window.d3, loaded + * before this file. API parity with window.SkyWheel / window.SeaDeal: + * SeedMap.draw(svgEl, seeds) — render the dual graph for an explicit seed array of [x,y] + * SeedMap.drawPlaceholder(svgEl) — STEP-1 placeholder seeds sized to the svg's box + * SeedMap.clear(svgEl) — empty the svg + */ +(function () { + 'use strict'; + var SVGNS = 'http://www.w3.org/2000/svg'; + + function _box(svgEl) { + var w = svgEl.clientWidth, h = svgEl.clientHeight; + if ((!w || !h) && svgEl.getBoundingClientRect) { + var r = svgEl.getBoundingClientRect(); + w = w || Math.round(r.width); + h = h || Math.round(r.height); + } + return [w, h]; + } + + function clear(svgEl) { + while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild); + } + + function _g(cls) { + var g = document.createElementNS(SVGNS, 'g'); + g.setAttribute('class', cls); + return g; + } + + // PLACEHOLDER seeds: a centre point + two staggered concentric rings. + // Deterministic (no Math.random) so the FT's cell/edge counts are stable. + function placeholderSeeds(w, h) { + var cx = w / 2, cy = h / 2, span = Math.min(w, h); + var pts = [[cx, cy]]; + var rings = [[6, span * 0.32], [6, span * 0.46]]; + for (var ri = 0; ri < rings.length; ri++) { + var n = rings[ri][0], rad = rings[ri][1], off = (ri * Math.PI) / n; + for (var i = 0; i < n; i++) { + var a = off + (i / n) * 2 * Math.PI; + pts.push([cx + rad * Math.cos(a), cy + rad * Math.sin(a)]); + } + } + return pts; + } + + function draw(svgEl, seeds) { + var d3 = window.d3; + if (!d3 || !d3.Delaunay || !svgEl || !seeds || seeds.length < 3) return; + var box = _box(svgEl), w = box[0], h = box[1]; + if (!w || !h) return; // 0-box guard — felt measured before it has layout + clear(svgEl); + + var delaunay = d3.Delaunay.from(seeds); + var voronoi = delaunay.voronoi([0, 0, w, h]); + + // ── Territory layer: one bounded Voronoi cell per seed. + var cells = _g('seed-cells'); + // d3 stamps the TRUE seed index on each yielded polygon as ring.index; + // cellPolygons() SKIPS degenerate/clipped cells, so a loop counter would drift + // from the seed id once seeds can coincide (Step 2 card seeds). Use ring.index. + Array.from(voronoi.cellPolygons()).forEach(function (ring) { + var d = 'M' + ring.map(function (p) { return p[0] + ',' + p[1]; }).join('L') + 'Z'; + var path = document.createElementNS(SVGNS, 'path'); + path.setAttribute('class', 'voronoi-cell'); + path.setAttribute('d', d); + path.setAttribute('data-seed', ring.index); + cells.appendChild(path); + }); + svgEl.appendChild(cells); + + // ── Adjacency layer: de-duped Delaunay edges (each unordered pair once). + var edges = _g('seed-edges'); + var tri = delaunay.triangles, seen = {}; + for (var t = 0; t < tri.length; t += 3) { + for (var e = 0; e < 3; e++) { + var a = tri[t + e], b = tri[t + ((e + 1) % 3)]; + var key = a < b ? a + '-' + b : b + '-' + a; + if (seen[key]) continue; + seen[key] = 1; + var pa = seeds[a], pb = seeds[b]; + var line = document.createElementNS(SVGNS, 'line'); + line.setAttribute('class', 'delaunay-edge'); + line.setAttribute('x1', pa[0]); line.setAttribute('y1', pa[1]); + line.setAttribute('x2', pb[0]); line.setAttribute('y2', pb[1]); + edges.appendChild(line); + } + } + svgEl.appendChild(edges); + + // ── Seed dots (placeholder in STEP 1; card-anchored in STEP 2). + var dots = _g('seed-points'); + seeds.forEach(function (p) { + var dot = document.createElementNS(SVGNS, 'circle'); + dot.setAttribute('class', 'voronoi-seed'); + dot.setAttribute('cx', p[0]); dot.setAttribute('cy', p[1]); + dot.setAttribute('r', 2.5); + dots.appendChild(dot); + }); + svgEl.appendChild(dots); + } + + function drawPlaceholder(svgEl) { + var box = _box(svgEl); + draw(svgEl, placeholderSeeds(box[0], box[1])); + } + + window.SeedMap = { + draw: draw, + drawPlaceholder: drawPlaceholder, + clear: clear, + placeholderSeeds: placeholderSeeds, + }; +}()); diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py new file mode 100644 index 0000000..13ffee9 --- /dev/null +++ b/src/functional_tests/test_game_room_seed_map.py @@ -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"), []) diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 4c2042f..a0c0602 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -35,6 +35,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/src/static/tests/VoronoiMapSpec.js b/src/static/tests/VoronoiMapSpec.js new file mode 100644 index 0000000..9bfee21 --- /dev/null +++ b/src/static/tests/VoronoiMapSpec.js @@ -0,0 +1,72 @@ +// ── VoronoiMapSpec.js ─────────────────────────────────────────────────────── +// +// Unit specs for voronoi-map.js — the SEED MAP dual-graph renderer (Voronoi map, +// roadmap step 21, Step 1). Verifies the two SVG layers paint from a seed array: +// g.seed-cells > path.voronoi-cell — Voronoi territory cells (one per seed) +// g.seed-edges > line.delaunay-edge — Delaunay adjacency edges +// +// DOM contract: as the draw target. +// Requires window.d3 (with d3-delaunay) + window.SeedMap loaded before this spec. +// ───────────────────────────────────────────────────────────────────────────── + +describe("SeedMap — d3-delaunay dual graph", () => { + // A clean, non-degenerate triangle — 3 seeds in general position give an + // UNAMBIGUOUS triangulation: 3 bounded cells + 3 edges (no cocircularity). + const TRIANGLE_SEEDS = [[100, 100], [300, 120], [200, 300]]; + + let svgEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_seed_map_svg"); + svgEl.setAttribute("class", "voronoi-map"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + }); + + afterEach(() => { + SeedMap.clear(svgEl); + svgEl.remove(); + }); + + it("draws one Voronoi cell per seed (the territory layer)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + expect(svgEl.querySelectorAll("g.seed-cells path.voronoi-cell").length) + .toBe(TRIANGLE_SEEDS.length); + }); + + it("draws the Delaunay adjacency edges (the movement overlay)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + // One triangle → exactly 3 unique (de-duped) edges. + expect(svgEl.querySelectorAll("g.seed-edges line.delaunay-edge").length) + .toBe(3); + }); + + it("renders the two layers as distinct groups", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + expect(svgEl.querySelectorAll("g.seed-cells").length).toBe(1); + expect(svgEl.querySelectorAll("g.seed-edges").length).toBe(1); + }); + + it("clear() empties the svg and re-draw is idempotent (no doubling)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + SeedMap.clear(svgEl); + expect(svgEl.children.length).toBe(0); + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + SeedMap.draw(svgEl, TRIANGLE_SEEDS); // second draw clears the first + expect(svgEl.querySelectorAll("path.voronoi-cell").length) + .toBe(TRIANGLE_SEEDS.length); + }); + + it("drawPlaceholder seeds the dual graph from the svg box (STEP-1 default)", () => { + SeedMap.drawPlaceholder(svgEl); + // placeholderSeeds = 1 centre + two rings of 6 = 13 bounded cells. + expect(svgEl.querySelectorAll("path.voronoi-cell").length).toBe(13); + // A 13-point triangulation has well more than a spanning tree of edges. + expect(svgEl.querySelectorAll("line.delaunay-edge").length) + .toBeGreaterThanOrEqual(12); + }); +}); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index b78ce6e..6cba38a 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -115,6 +115,13 @@ html.sea-open .room-aperture.is-scrollable { scroll-snap-type: none; } +// Same pin while the SEED MAP felt is up (html.seed-open) — the felt fills the +// hex pane + the reelhouse below must stay out of reach. Mirrors sky/sea. +html.seed-open .room-aperture.is-scrollable { + overflow-y: hidden; + scroll-snap-type: none; +} + .room-scroll-pane { display: flex; flex-direction: column; diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index d909d45..91ab400 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -198,6 +198,83 @@ html.sea-open .position-strip { transition: opacity 0.5s ease; } +// ── In-room SEED MAP felt (roadmap step 21 — Voronoi/Delaunay dual graph) ────── +// Third --duoUser felt sibling of CAST SKY + DRAW SEA, rendered INSIDE +// .room-hex-pane (my_sea-style). Holds the d3-delaunay dual graph: a Voronoi CELL +// layer (territory) + a Delaunay EDGE overlay (adjacency). Hidden until +// #id_seed_map_btn adds html.seed-open; opens via a clean SWAP (openSeed() first +// closeSkyFelt()/closeSeaFelt() — three equal-z felts now, trap T3). +html.seed-open { + overflow: hidden; +} + +.room-hex-pane.has-seed-stage { + position: relative; +} + +// Chained (0,2,0) to mirror .sky-page--room / .sea-page--room and stay robust +// against any future bare `.seed-page` rule; the felt must be absolutely +// positioned inside the relative hex-pane. [[feedback-scss-import-order-specificity]] +.seed-page.seed-page--room { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: rgba(var(--duoUser), 1); // own --duoUser surface — covers the hex + visibility: hidden; // hidden until opened; can't eat the SEED MAP btn click beneath it + pointer-events: none; +} + +html.seed-open .seed-page.seed-page--room { + visibility: visible; + pointer-events: auto; +} + +// The inner wrappers fill the pane so the svg's 100%/100% resolves against the +// full hex-pane box — else the flex-centred wrappers shrink-wrap the svg to its +// ~300px intrinsic size and the map floats as a small centred box. +.seed-page--room .seed-map-body, +.seed-page--room .seed-map-col { + width: 100%; + height: 100%; +} + +// The dual-graph canvas fills the felt. Layers are styled by class so STEP 2 +// (card-driven territoriality) can recolour cells without touching layout. +.seed-page--room svg.voronoi-map { + display: block; + width: 100%; + height: 100%; + + .voronoi-cell { // territory base — filled cells + fill: rgba(var(--priUser), 0.18); + stroke: rgba(var(--secUser), 0.55); + stroke-width: 1; + } + .delaunay-edge { // adjacency overlay — drawn on top + fill: none; + stroke: rgba(var(--secUser), 0.9); + stroke-width: 0.75; + stroke-linecap: round; + } + .voronoi-seed { // placeholder seed dots (STEP 1) + fill: rgba(var(--secUser), 1); + } +} + +// Hide the z-130 position strip while the felt is up (mirrors sky/sea). +html.seed-open .position-strip { + visibility: hidden; +} + +// NB: NO `html.seed-open #id_aperture_fill { opacity: 1 }` rule — the z-90 fill +// must stay transparent over this z-5 felt, else its 0.15s opacity transition +// paints an opaque --duoUser sheet OVER the map (flash-then-vanish). The felt +// covers the hex with its own --duoUser bg. [[feedback-felt-aperture-fill-covers-felt]] + // ── Backdrop ────────────────────────────────────────────────────────────────── .sky-backdrop { diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 4c2042f..a0c0602 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -35,6 +35,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/src/static_src/tests/VoronoiMapSpec.js b/src/static_src/tests/VoronoiMapSpec.js new file mode 100644 index 0000000..9bfee21 --- /dev/null +++ b/src/static_src/tests/VoronoiMapSpec.js @@ -0,0 +1,72 @@ +// ── VoronoiMapSpec.js ─────────────────────────────────────────────────────── +// +// Unit specs for voronoi-map.js — the SEED MAP dual-graph renderer (Voronoi map, +// roadmap step 21, Step 1). Verifies the two SVG layers paint from a seed array: +// g.seed-cells > path.voronoi-cell — Voronoi territory cells (one per seed) +// g.seed-edges > line.delaunay-edge — Delaunay adjacency edges +// +// DOM contract: as the draw target. +// Requires window.d3 (with d3-delaunay) + window.SeedMap loaded before this spec. +// ───────────────────────────────────────────────────────────────────────────── + +describe("SeedMap — d3-delaunay dual graph", () => { + // A clean, non-degenerate triangle — 3 seeds in general position give an + // UNAMBIGUOUS triangulation: 3 bounded cells + 3 edges (no cocircularity). + const TRIANGLE_SEEDS = [[100, 100], [300, 120], [200, 300]]; + + let svgEl; + + beforeEach(() => { + svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgEl.setAttribute("id", "id_seed_map_svg"); + svgEl.setAttribute("class", "voronoi-map"); + svgEl.setAttribute("width", "400"); + svgEl.setAttribute("height", "400"); + svgEl.style.width = "400px"; + svgEl.style.height = "400px"; + document.body.appendChild(svgEl); + }); + + afterEach(() => { + SeedMap.clear(svgEl); + svgEl.remove(); + }); + + it("draws one Voronoi cell per seed (the territory layer)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + expect(svgEl.querySelectorAll("g.seed-cells path.voronoi-cell").length) + .toBe(TRIANGLE_SEEDS.length); + }); + + it("draws the Delaunay adjacency edges (the movement overlay)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + // One triangle → exactly 3 unique (de-duped) edges. + expect(svgEl.querySelectorAll("g.seed-edges line.delaunay-edge").length) + .toBe(3); + }); + + it("renders the two layers as distinct groups", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + expect(svgEl.querySelectorAll("g.seed-cells").length).toBe(1); + expect(svgEl.querySelectorAll("g.seed-edges").length).toBe(1); + }); + + it("clear() empties the svg and re-draw is idempotent (no doubling)", () => { + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + SeedMap.clear(svgEl); + expect(svgEl.children.length).toBe(0); + SeedMap.draw(svgEl, TRIANGLE_SEEDS); + SeedMap.draw(svgEl, TRIANGLE_SEEDS); // second draw clears the first + expect(svgEl.querySelectorAll("path.voronoi-cell").length) + .toBe(TRIANGLE_SEEDS.length); + }); + + it("drawPlaceholder seeds the dual graph from the svg box (STEP-1 default)", () => { + SeedMap.drawPlaceholder(svgEl); + // placeholderSeeds = 1 centre + two rings of 6 = 13 bounded cells. + expect(svgEl.querySelectorAll("path.voronoi-cell").length).toBe(13); + // A 13-point triangulation has well more than a spanning tree of edges. + expect(svgEl.querySelectorAll("line.delaunay-edge").length) + .toBeGreaterThanOrEqual(12); + }); +}); diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html index 1ad1914..35d4961 100644 --- a/src/templates/apps/gameboard/_partials/_room_gear.html +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -39,6 +39,11 @@ + {# Seed pane — shown by room-views.js (updateGear) while the SEED MAP felt is #} + {# open (html.seed-open). NVM returns to the table-hex. ?seat as above. #} +