gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

Nonet: 3 variantes de pista externa, dificultad real y menú en páginas

Variantes nuevas (16 en total): Rascacielos (visibilidad desde el borde),
Little Killer (sumas en diagonales) y Rossini (orden de las 3 celdas del borde).
El motor suma restricciones de visibilidad y diagonales-suma, con marco de
pistas alrededor del tablero.

Dificultad de verdad: fácil/medio se resuelven solo con lógica (singles), y
difícil/experto permiten razonamiento avanzado dejando muchas menos pistas
(23-30 vs 34-44). Verificado para las 16 variantes × 4 dificultades.

Menú rediseñado: pager horizontal arrastrable, una página por variante que
dibuja su forma particular (diagonales, ventanas, jaulas, flechas, etc.) con
su selector de dificultad y el historial de puzzles generados/cumplidos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

c02fd3a4d4a9b6e3a51ddd2f59112f3584e6ea12 · ruwaq Sergio · (4 killa ñawpaq) · tayta 6d5368c0b · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

5 qillqakuna · +467 −174

diff --git a/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
index 4aaffae..7fbbab6 100644
--- a/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/NonetViewModel.kt
@@ -81,11 +81,11 @@ class NonetViewModel(app: Application) : AndroidViewModel(app) {
 
     fun puzzleId(v: Variant, d: Dificultad, index: Int) = "${v.id}:${d.ordinal}:$index"
 
-    /** Genera el siguiente puzzle de la selección actual y lo abre. */
-    fun generarNuevo() {
+    /** Genera el siguiente puzzle de (variante, dificultad) y lo abre. */
+    fun generarNuevo(v: Variant = variante, d: Dificultad = dificultad) {
         viewModelScope.launch {
-            val index = progreso.generarSiguiente(variante.id, dificultad.ordinal)
-            abrir(variante, dificultad, index)
+            val index = progreso.generarSiguiente(v.id, d.ordinal)
+            abrir(v, d, index)
         }
     }
 
diff --git a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
index 3468fc8..faed257 100644
--- a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -3,31 +3,43 @@ package com.rizoma.nonet.game
 import com.rizoma.nonet.model.Dificultad
 import com.rizoma.nonet.model.Variant
 import java.util.Random
+import kotlin.math.abs
 
-/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad). */
+/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad, orden Rossini). */
 enum class Rel { CONSEC, NO_CONSEC, SUM5, SUM10, NO_5_10, LT, GT }
 
 /** Restricción binaria vista desde una celda: contra [other] debe cumplirse [rel]. */
 class BinPeer(val other: Int, val rel: Rel)
 
-/** Jaula de Killer: un conjunto de celdas que suma [sum] y no repite dígitos. */
-class Cage(val cells: IntArray, val sum: Int)
+/** Jaula/diagonal con suma. [noRepite] true en Killer; false en Little Killer (admite repes). */
+class Cage(val cells: IntArray, val sum: Int, val noRepite: Boolean = true)
+
+/** Restricción de visibilidad (rascacielos): por [cells] en orden de mirada, se ven [k]. */
+class Vis(val cells: IntArray, val k: Int)
 
 /** Marca a dibujar entre dos celdas adyacentes [a]-[b] (orientada para < >). */
 class Arista(val a: Int, val b: Int, val rel: Rel)
 
-/**
- * Todas las restricciones de un puzzle concreto, ya resueltas para el solver:
- * vecinos de no-repetición, dígitos permitidos por celda (paridad), relaciones
- * binarias por celda y jaulas. El clásico y las de "región extra" solo usan
- * [peers] (con [allowed] lleno y sin binarios ni jaulas).
- */
+/** Pistas del borde, para dibujar el marco de las variantes con pistas externas. */
+class Borde(
+    val sky: Array<IntArray>? = null,          // [lado 0=arr,1=ab,2=izq,3=der][9]; 0 = sin pista
+    val rossini: Array<IntArray>? = null,      // [lado][9]; 0=nada, 1=creciente hacia dentro, 2=decreciente
+    val littleKiller: List<LK> = emptyList(),  // flechas diagonales ancladas arriba
+)
+
+/** Flecha de Little Killer anclada sobre la columna [col]; dir 0=↘, 1=↗; suma de su diagonal. */
+class LK(val col: Int, val dir: Int, val sum: Int)
+
+/** Todas las restricciones de un puzzle, resueltas para el solver. */
 class Reglas(
+    val groups: List<IntArray>,
     val peers: Array<IntArray>,
     val allowed: IntArray,
     val bin: Array<List<BinPeer>>,
     val cagesOf: Array<List<Cage>>,
     val cages: List<Cage>,
+    val visOf: Array<List<Vis>>,
+    val vis: List<Vis>,
 )
 
 /** Un puzzle generado: pistas, solución única, reglas y datos para dibujar. */
@@ -40,6 +52,7 @@ class Puzzle(
     val reglas: Reglas,
     val regiones: IntArray?,    // jigsaw: región (0..8) por celda; null = cajas 3×3
     val aristas: List<Arista>,  // marcas entre celdas (consecutivo/XV/mayor-que)
+    val borde: Borde?,          // pistas externas (rascacielos/little killer/rossini)
 ) {
     val peers: Array<IntArray> get() = reglas.peers
     val id: String get() = "${variant.id}:${dificultad.ordinal}:$index"
@@ -47,11 +60,12 @@ class Puzzle(
 
 /**
  * Motor unificado de todas las variantes. Genera una solución completa bajo las
- * regiones de la variante, deriva de ella las restricciones propias (paridad,
- * marcas, jaulas) y cava celdas mientras la solución siga siendo única.
+ * regiones de la variante, deriva de ella sus restricciones (paridad, marcas,
+ * jaulas, pistas de borde) y cava celdas mientras la solución siga siendo única;
+ * en fácil/medio, además, exigiendo que se resuelva solo con lógica de singles.
  */
 object Motor {
-    private const val TODOS = 0x3FE                 // bits 1..9
+    private const val TODOS = 0x3FE
     private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
     private const val IMPARES = (1 shl 1) or (1 shl 3) or (1 shl 5) or (1 shl 7) or (1 shl 9)
 
@@ -65,139 +79,208 @@ object Motor {
         baseGroups.addAll(regiones?.let { regionGroups(it) } ?: boxes())
         baseGroups.addAll(v.extraGroups())
 
-        // Solución completa bajo las regiones (las demás restricciones se derivan).
         val solution = IntArray(81)
-        fillFull(solution, soloPeers(peersOf(baseGroups)), rng)
+        fillFull(solution, soloPeers(baseGroups), rng)
 
-        val (reglas, aristas) = construir(v, solution, baseGroups, rng)
+        val construido = construir(v, solution, baseGroups, rng)
 
         val givens = solution.copyOf()
-        dig(givens, reglas, dif.pistasObjetivo, rng)
+        dig(givens, construido.reglas, dif.pistasObjetivo, dif.soloLogica, rng)
 
-        return Puzzle(v, dif, index, givens, solution, reglas, regiones, aristas)
+        return Puzzle(v, dif, index, givens, solution, construido.reglas, regiones, construido.aristas, construido.borde)
     }
 
-    /** Cuenta soluciones de [grid] bajo [reglas] hasta [limite]. */
     fun contarSoluciones(grid: IntArray, reglas: Reglas, limite: Int): Int =
         count(grid.copyOf(), reglas, limite)
 
+    /** Una distribución jigsaw fija (para ilustrar la variante en el menú). */
+    fun jigsawEjemplo(): IntArray = jigsaw(Random(7))
+
     // ---- Construcción de reglas por variante --------------------------
 
-    private fun construir(
-        v: Variant,
-        sol: IntArray,
-        baseGroups: List<IntArray>,
-        rng: Random,
-    ): Pair<Reglas, List<Arista>> {
+    private class Construido(val reglas: Reglas, val aristas: List<Arista>, val borde: Borde?)
+
+    private fun construir(v: Variant, sol: IntArray, baseGroups: List<IntArray>, rng: Random): Construido {
         val allowed = IntArray(81) { TODOS }
         val bin = Array(81) { ArrayList<BinPeer>() }
         val aristas = ArrayList<Arista>()
         var cages = emptyList<Cage>()
+        var vis = emptyList<Vis>()
         var groups = baseGroups
+        var borde: Borde? = null
 
         when (v.tipo) {
             Variant.Tipo.PARIDAD ->
                 for (i in 0 until 81) allowed[i] = if (sol[i] % 2 == 0) PARES else IMPARES
 
-            Variant.Tipo.CONSEC -> adyacentes(soloOrtogonales = true) { a, b ->
-                val consec = kotlin.math.abs(sol[a] - sol[b]) == 1
-                bin[a].add(BinPeer(b, if (consec) Rel.CONSEC else Rel.NO_CONSEC))
-                bin[b].add(BinPeer(a, if (consec) Rel.CONSEC else Rel.NO_CONSEC))
+            Variant.Tipo.CONSEC -> adyacentes(true) { a, b ->
+                val consec = abs(sol[a] - sol[b]) == 1
+                val rel = if (consec) Rel.CONSEC else Rel.NO_CONSEC
+                bin[a].add(BinPeer(b, rel)); bin[b].add(BinPeer(a, rel))
                 if (consec) aristas.add(Arista(a, b, Rel.CONSEC))
             }
 
-            Variant.Tipo.XV -> adyacentes(soloOrtogonales = true) { a, b ->
-                val s = sol[a] + sol[b]
-                val rel = when (s) { 5 -> Rel.SUM5; 10 -> Rel.SUM10; else -> Rel.NO_5_10 }
+            Variant.Tipo.XV -> adyacentes(true) { a, b ->
+                val rel = when (sol[a] + sol[b]) { 5 -> Rel.SUM5; 10 -> Rel.SUM10; else -> Rel.NO_5_10 }
                 bin[a].add(BinPeer(b, rel)); bin[b].add(BinPeer(a, rel))
                 if (rel != Rel.NO_5_10) aristas.add(Arista(a, b, rel))
             }
 
-            Variant.Tipo.MAYOR -> adyacentes(soloOrtogonales = true, mismaCaja = true) { a, b ->
-                // a<b: a lleva LT contra b; b lleva GT contra a. Arista orientada a→b.
+            Variant.Tipo.MAYOR -> adyacentes(true, mismaCaja = true) { a, b ->
                 if (sol[a] < sol[b]) {
-                    bin[a].add(BinPeer(b, Rel.LT)); bin[b].add(BinPeer(a, Rel.GT))
-                    aristas.add(Arista(a, b, Rel.LT))
+                    bin[a].add(BinPeer(b, Rel.LT)); bin[b].add(BinPeer(a, Rel.GT)); aristas.add(Arista(a, b, Rel.LT))
                 } else {
-                    bin[a].add(BinPeer(b, Rel.GT)); bin[b].add(BinPeer(a, Rel.LT))
-                    aristas.add(Arista(a, b, Rel.GT))
+                    bin[a].add(BinPeer(b, Rel.GT)); bin[b].add(BinPeer(a, Rel.LT)); aristas.add(Arista(a, b, Rel.GT))
                 }
             }
 
             Variant.Tipo.KILLER -> {
                 cages = jaulas(sol, rng)
-                // El no-repetir de cada jaula se enforza vía peers.
                 groups = baseGroups + cages.map { it.cells }
             }
 
-            else -> {} // REGION / JIGSAW: solo regiones
+            Variant.Tipo.SKY -> {
+                val vlist = ArrayList<Vis>()
+                val top = IntArray(9); val bot = IntArray(9); val izq = IntArray(9); val der = IntArray(9)
+                for (i in 0 until 9) {
+                    val fila = IntArray(9) { i * 9 + it }
+                    val col = IntArray(9) { it * 9 + i }
+                    izq[i] = visibles(fila.map { sol[it] }); vlist.add(Vis(fila, izq[i]))
+                    val filaR = fila.reversedArray(); der[i] = visibles(filaR.map { sol[it] }); vlist.add(Vis(filaR, der[i]))
+                    top[i] = visibles(col.map { sol[it] }); vlist.add(Vis(col, top[i]))
+                    val colR = col.reversedArray(); bot[i] = visibles(colR.map { sol[it] }); vlist.add(Vis(colR, bot[i]))
+                }
+                vis = vlist
+                borde = Borde(sky = arrayOf(top, bot, izq, der))
+            }
+
+            Variant.Tipo.LITTLE -> {
+                val arrows = ArrayList<LK>()
+                val cs = ArrayList<Cage>()
+                // ↘ desde columnas pares, ↗ desde columnas impares (ancladas arriba).
+                for (c in 0 until 9) {
+                    val dir = if (c % 2 == 0) 0 else 1
+                    val cells = ArrayList<Int>()
+                    var rr = 0; var cc = c
+                    while (rr in 0..8 && cc in 0..8) { cells.add(rr * 9 + cc); rr++; cc += if (dir == 0) 1 else -1 }
+                    if (cells.size >= 3) {
+                        val suma = cells.sumOf { sol[it] }
+                        cs.add(Cage(cells.toIntArray(), suma, noRepite = false))
+                        arrows.add(LK(c, dir, suma))
+                    }
+                }
+                cages = cs
+                borde = Borde(littleKiller = arrows)
+            }
+
+            Variant.Tipo.ROSSINI -> {
+                val ros = Array(4) { IntArray(9) }
+                // lado 0=arr (mira hacia abajo), 1=ab (hacia arriba), 2=izq (derecha), 3=der (izquierda)
+                for (i in 0 until 9) {
+                    triple(0, i)?.let { t -> rossiniMarca(sol, t, bin, aristasNo = true)?.let { ros[0][i] = it } }
+                    triple(1, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[1][i] = it } }
+                    triple(2, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[2][i] = it } }
+                    triple(3, i)?.let { t -> rossiniMarca(sol, t, bin)?.let { ros[3][i] = it } }
+                }
+                borde = Borde(rossini = ros)
+            }
+
+            else -> {}
         }
 
         val peers = peersOf(groups)
         val cagesOf = Array(81) { ArrayList<Cage>() }
         for (c in cages) for (i in c.cells) cagesOf[i].add(c)
+        val visOf = Array(81) { ArrayList<Vis>() }
+        for (vz in vis) for (i in vz.cells) visOf[i].add(vz)
+
         val reglas = Reglas(
+            groups = groups,
             peers = peers,
             allowed = allowed,
             bin = Array(81) { bin[it] },
             cagesOf = Array(81) { cagesOf[it] },
             cages = cages,
+            visOf = Array(81) { visOf[it] },
+            vis = vis,
         )
-        return reglas to aristas
+        return Construido(reglas, aristas, borde)
     }
 
-    // ---- Solver (peers + allowed + binarios + jaulas) -----------------
+    /** Las 3 celdas más cercanas a un lado, en orden hacia dentro. */
+    private fun triple(lado: Int, i: Int): IntArray? = when (lado) {
+        0 -> intArrayOf(i, 9 + i, 18 + i)              // columna i desde arriba
+        1 -> intArrayOf(72 + i, 63 + i, 54 + i)        // columna i desde abajo
+        2 -> intArrayOf(i * 9, i * 9 + 1, i * 9 + 2)   // fila i desde izquierda
+        3 -> intArrayOf(i * 9 + 8, i * 9 + 7, i * 9 + 6) // fila i desde derecha
+        else -> null
+    }
+
+    /** Si el triple es estrictamente monótono, añade los binarios y devuelve 1/2 (cre/dec). */
+    private fun rossiniMarca(sol: IntArray, t: IntArray, bin: Array<ArrayList<BinPeer>>, aristasNo: Boolean = false): Int? {
+        val a = sol[t[0]]; val b = sol[t[1]]; val c = sol[t[2]]
+        val creciente = a < b && b < c
+        val decreciente = a > b && b > c
+        if (!creciente && !decreciente) return null
+        val rel = if (creciente) Rel.LT else Rel.GT
+        val inv = if (creciente) Rel.GT else Rel.LT
+        bin[t[0]].add(BinPeer(t[1], rel)); bin[t[1]].add(BinPeer(t[0], inv))
+        bin[t[1]].add(BinPeer(t[2], rel)); bin[t[2]].add(BinPeer(t[1], inv))
+        return if (creciente) 1 else 2
+    }
+
+    private fun visibles(linea: List<Int>): Int {
+        var max = 0; var n = 0
+        for (v in linea) if (v > max) { max = v; n++ }
+        return n
+    }
+
+    // ---- Solver -------------------------------------------------------
 
     private const val RESUELTO = -2
     private const val SIN_SALIDA = -1
 
-    /** Candidatos válidos de la celda [c]: permitidos − usados por peers − binarios. */
     private fun candMask(grid: IntArray, c: Int, reglas: Reglas): Int {
         var m = reglas.allowed[c] and TODOS
         for (p in reglas.peers[c]) { val v = grid[p]; if (v != 0) m = m and (1 shl v).inv() }
-        for (bp in reglas.bin[c]) {
-            val e = grid[bp.other]
-            if (e != 0) m = m and permitidosPorRel(e, bp.rel)
-        }
+        for (bp in reglas.bin[c]) { val e = grid[bp.other]; if (e != 0) m = m and permitidosPorRel(e, bp.rel) }
         return m
     }
 
-    /** Máscara de dígitos d que cumplen rel(d, [e]) contra el vecino con valor [e]. */
     private fun permitidosPorRel(e: Int, rel: Rel): Int {
-        var m = 0
-        var d = 1
+        var m = 0; var d = 1
         while (d <= 9) {
             val ok = when (rel) {
-                Rel.CONSEC -> kotlin.math.abs(d - e) == 1
-                Rel.NO_CONSEC -> kotlin.math.abs(d - e) != 1
+                Rel.CONSEC -> abs(d - e) == 1
+                Rel.NO_CONSEC -> abs(d - e) != 1
                 Rel.SUM5 -> d + e == 5
                 Rel.SUM10 -> d + e == 10
                 Rel.NO_5_10 -> d + e != 5 && d + e != 10
                 Rel.LT -> d < e
                 Rel.GT -> d > e
             }
-            if (ok) m = m or (1 shl d)
-            d++
+            if (ok) m = m or (1 shl d); d++
         }
         return m
     }
 
-    /** ¿Las jaulas que tocan [c] siguen siendo viables tras colocar un dígito? */
-    private fun cagesOk(grid: IntArray, c: Int, reglas: Reglas): Boolean {
+    /** Tras colocar un dígito en [c], ¿siguen viables sus jaulas y visibilidades? */
+    private fun restriccionesOk(grid: IntArray, c: Int, reglas: Reglas): Boolean {
         for (cage in reglas.cagesOf[c]) {
             var suma = 0; var llenas = 0
             for (i in cage.cells) { val v = grid[i]; if (v != 0) { suma += v; llenas++ } }
             val faltan = cage.cells.size - llenas
-            if (faltan == 0) {
-                if (suma != cage.sum) return false
-            } else {
-                // Cota: cada celda restante aporta ≥1 distinto y ≤ (9,8,…).
+            if (faltan == 0) { if (suma != cage.sum) return false } else {
                 if (suma + faltan > cage.sum) return false
-                var maxAdd = 0; var d = 9
-                repeat(faltan) { maxAdd += d; d-- }
+                val maxAdd = if (cage.noRepite) { var s = 0; var d = 9; repeat(faltan) { s += d; d-- }; s } else faltan * 9
                 if (suma + maxAdd < cage.sum) return false
             }
         }
+        for (vz in reglas.visOf[c]) {
+            if (vz.cells.all { grid[it] != 0 }) {
+                if (visibles(vz.cells.map { grid[it] }) != vz.k) return false
+            }
+        }
         return true
     }
 
@@ -208,7 +291,7 @@ object Motor {
         val digits = digitsOf(mask).also { shuffle(it, rng) }
         for (d in digits) {
             grid[idx] = d
-            if (cagesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
+            if (restriccionesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
             grid[idx] = 0
         }
         return false
@@ -218,12 +301,11 @@ object Motor {
         val (idx, mask) = mrv(grid, reglas)
         if (idx == RESUELTO) return 1
         if (idx == SIN_SALIDA) return 0
-        var total = 0
-        var d = 1
+        var total = 0; var d = 1
         while (d <= 9) {
             if (mask and (1 shl d) != 0) {
                 grid[idx] = d
-                if (cagesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
+                if (restriccionesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
                 grid[idx] = 0
                 if (total >= limite) break
             }
@@ -244,18 +326,45 @@ object Motor {
         return bestIdx to bestMask
     }
 
-    private fun dig(grid: IntArray, reglas: Reglas, pistasObjetivo: Int, rng: Random) {
+    /** ¿Se resuelve solo con singles (desnudos + ocultos)? Mide la dificultad "lógica". */
+    private fun resolublePorSingles(base: IntArray, reglas: Reglas): Boolean {
+        val grid = base.copyOf()
+        while (true) {
+            var avance = false
+            for (c in 0 until 81) if (grid[c] == 0) {
+                val m = candMask(grid, c, reglas)
+                if (m == 0) return false
+                if (Integer.bitCount(m) == 1) { grid[c] = 31 - Integer.numberOfLeadingZeros(m); avance = true }
+            }
+            for (grp in reglas.groups) {
+                var d = 1
+                while (d <= 9) {
+                    if (grp.none { grid[it] == d }) {
+                        var unico = -1; var cuenta = 0
+                        for (cell in grp) if (grid[cell] == 0 && candMask(grid, cell, reglas) and (1 shl d) != 0) { unico = cell; cuenta++ }
+                        if (cuenta == 1) { grid[unico] = d; avance = true }
+                    }
+                    d++
+                }
+            }
+            if (grid.none { it == 0 }) return true
+            if (!avance) return false
+        }
+    }
+
+    private fun dig(grid: IntArray, reglas: Reglas, objetivo: Int, soloLogica: Boolean, rng: Random) {
         val order = IntArray(81) { it }.also { shuffle(it, rng) }
         var pistas = 81
         for (c in order) {
-            if (pistas <= pistasObjetivo) break
+            if (pistas <= objetivo) break
             val saved = grid[c]
             grid[c] = 0
-            if (count(grid.copyOf(), reglas, 2) != 1) grid[c] = saved else pistas--
+            val ok = count(grid.copyOf(), reglas, 2) == 1 && (!soloLogica || resolublePorSingles(grid, reglas))
+            if (!ok) grid[c] = saved else pistas--
         }
     }
 
-    // ---- Regiones -----------------------------------------------------
+    // ---- Regiones / jaulas --------------------------------------------
 
     private fun boxes(): List<IntArray> = (0 until 9).map { b ->
         val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
@@ -265,31 +374,22 @@ object Motor {
     private fun regionGroups(reg: IntArray): List<IntArray> =
         (0 until 9).map { id -> (0 until 81).filter { reg[it] == id }.toIntArray() }
 
-    /** 9 regiones conexas de 9 celdas (jigsaw), creciendo la más pequeña con vecino libre. */
     private fun jigsaw(rng: Random): IntArray {
         repeat(300) {
-            val reg = IntArray(81) { -1 }
-            val sizes = IntArray(9)
+            val reg = IntArray(81) { -1 }; val sizes = IntArray(9)
             val seeds = (0 until 81).toMutableList().also { shuffle2(it, rng) }.take(9)
             seeds.forEachIndexed { id, cell -> reg[cell] = id; sizes[id] = 1 }
-            var faltan = 81 - 9
-            var atascado = false
+            var faltan = 81 - 9; var atascado = false
             while (faltan > 0 && !atascado) {
-                // región más pequeña (<9) con algún vecino libre
                 var elegida = -1; var menor = 10
-                for (id in 0 until 9) if (sizes[id] in 1 until 9 && sizes[id] < menor && tieneVecinoLibre(reg, id)) {
-                    menor = sizes[id]; elegida = id
-                }
+                for (id in 0 until 9) if (sizes[id] in 1 until 9 && sizes[id] < menor && tieneVecinoLibre(reg, id)) { menor = sizes[id]; elegida = id }
                 if (elegida == -1) { atascado = true; break }
                 val libres = vecinosLibres(reg, elegida)
-                val cell = libres[rng.nextInt(libres.size)]
-                reg[cell] = elegida; sizes[elegida]++; faltan--
+                reg[libres[rng.nextInt(libres.size)]] = elegida; sizes[elegida]++; faltan--
             }
             if (!atascado && sizes.all { it == 9 }) return reg
         }
-        // Fallback improbable: cajas 3×3.
-        val reg = IntArray(81); for (i in 0 until 81) reg[i] = (i / 9 / 3) * 3 + (i % 9 / 3)
-        return reg
+        return IntArray(81) { (it / 9 / 3) * 3 + (it % 9 / 3) }
     }
 
     private fun tieneVecinoLibre(reg: IntArray, id: Int): Boolean {
@@ -303,27 +403,23 @@ object Motor {
         return s.toIntArray()
     }
 
-    // ---- Jaulas (Killer) ----------------------------------------------
-
     private fun jaulas(sol: IntArray, rng: Random): List<Cage> {
-        val asign = IntArray(81) { -1 }
-        val cages = ArrayList<Cage>()
+        val asign = IntArray(81) { -1 }; val cages = ArrayList<Cage>()
         val order = IntArray(81) { it }.also { shuffle(it, rng) }
         for (start in order) {
             if (asign[start] != -1) continue
-            val objetivo = 2 + rng.nextInt(3) // 2..4
+            val objetivo = 2 + rng.nextInt(3)
             val celdas = ArrayList<Int>().apply { add(start) }
             asign[start] = cages.size
             val usados = HashSet<Int>().apply { add(sol[start]) }
             while (celdas.size < objetivo) {
-                // vecino libre de cualquier celda de la jaula cuyo dígito no se repita
                 val cand = ArrayList<Int>()
                 for (cl in celdas) for (n in ortog(cl)) if (asign[n] == -1 && sol[n] !in usados) cand.add(n)
                 if (cand.isEmpty()) break
                 val n = cand[rng.nextInt(cand.size)]
                 asign[n] = cages.size; celdas.add(n); usados.add(sol[n])
             }
-            cages.add(Cage(celdas.toIntArray(), celdas.sumOf { sol[it] }))
+            cages.add(Cage(celdas.toIntArray(), celdas.sumOf { sol[it] }, noRepite = true))
         }
         return cages
     }
@@ -336,25 +432,24 @@ object Motor {
         return Array(81) { sets[it].toIntArray() }
     }
 
-    /** Reglas con solo peers (allowed lleno, sin binarios ni jaulas): para generar la solución. */
-    private fun soloPeers(peers: Array<IntArray>) =
-        Reglas(peers, IntArray(81) { TODOS }, Array(81) { emptyList<BinPeer>() }, Array(81) { emptyList<Cage>() }, emptyList())
+    private fun soloPeers(groups: List<IntArray>) = Reglas(
+        groups, peersOf(groups), IntArray(81) { TODOS },
+        Array(81) { emptyList<BinPeer>() }, Array(81) { emptyList<Cage>() }, emptyList(),
+        Array(81) { emptyList<Vis>() }, emptyList(),
+    )
 
-    /** Vecinos ortogonales (arriba/abajo/izq/der) de una celda. */
     private fun ortog(i: Int): IntArray {
-        val r = i / 9; val c = i % 9
-        val out = ArrayList<Int>(4)
+        val r = i / 9; val c = i % 9; val out = ArrayList<Int>(4)
         if (r > 0) out.add(i - 9); if (r < 8) out.add(i + 9)
         if (c > 0) out.add(i - 1); if (c < 8) out.add(i + 1)
         return out.toIntArray()
     }
 
-    /** Recorre cada par adyacente una vez (derecha y abajo), opcionalmente solo dentro de caja. */
     private inline fun adyacentes(soloOrtogonales: Boolean, mismaCaja: Boolean = false, accion: (Int, Int) -> Unit) {
         for (r in 0 until 9) for (c in 0 until 9) {
             val a = r * 9 + c
-            if (c < 8) { val b = a + 1; if (!mismaCaja || c % 3 != 2) accion(a, b) }
-            if (r < 8) { val b = a + 9; if (!mismaCaja || r % 3 != 2) accion(a, b) }
+            if (c < 8 && (!mismaCaja || c % 3 != 2)) accion(a, a + 1)
+            if (r < 8 && (!mismaCaja || r % 3 != 2)) accion(a, a + 9)
         }
     }
 
@@ -366,8 +461,7 @@ object Motor {
     }
 
     private fun digitsOf(mask: Int): IntArray {
-        val out = ArrayList<Int>(9)
-        var d = 1
+        val out = ArrayList<Int>(9); var d = 1
         while (d <= 9) { if (mask and (1 shl d) != 0) out.add(d); d++ }
         return out.toIntArray()
     }
diff --git a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
index f7e72c8..286049f 100644
--- a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -80,13 +80,28 @@ enum class Variant(
         "killer", "Killer",
         "Cada jaula punteada suma el número indicado y no repite dígitos.",
         Tipo.KILLER, Realce.JAULAS,
+    ),
+    SKYSCRAPER(
+        "skyscraper", "Rascacielos",
+        "Las pistas del borde dicen cuántos 'rascacielos' se ven desde ese lado (los más altos tapan a los más bajos).",
+        Tipo.SKY, Realce.BORDE,
+    ),
+    LITTLE_KILLER(
+        "littlekiller", "Little Killer",
+        "Cada flecha diagonal indica la suma de todos los dígitos de su diagonal (pueden repetirse).",
+        Tipo.LITTLE, Realce.BORDE,
+    ),
+    ROSSINI(
+        "rossini", "Rossini",
+        "Una flecha en el borde indica que las tres casillas más cercanas van en orden creciente hacia donde apunta.",
+        Tipo.ROSSINI, Realce.BORDE,
     );
 
     /** Familia de la variante: determina qué restricciones añade el motor. */
-    enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER }
+    enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER, SKY, LITTLE, ROSSINI }
 
     /** Cómo se dibuja la restricción extra en el tablero. */
-    enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS }
+    enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS, BORDE }
 
     /**
      * Grupos extra (cada uno, 9 índices) de las variantes de "región extra".
@@ -121,10 +136,15 @@ enum class Variant(
     }
 }
 
-/** Las cuatro dificultades, con cuántas pistas dejar al cavar el puzzle. */
-enum class Dificultad(val titulo: String, val pistasObjetivo: Int) {
-    FACIL("fácil", 46),
-    MEDIO("medio", 38),
-    DIFICIL("difícil", 31),
-    EXPERTO("experto", 26),
+/**
+ * Las cuatro dificultades. [pistasObjetivo] es hasta cuántas pistas cavar;
+ * [soloLogica] exige que el puzzle se resuelva por singles (sin adivinar), lo
+ * que hace fáciles a fácil/medio. Difícil/experto permiten razonamiento avanzado
+ * y dejan muchas menos pistas: por eso son de verdad más difíciles.
+ */
+enum class Dificultad(val titulo: String, val pistasObjetivo: Int, val soloLogica: Boolean) {
+    FACIL("fácil", 44, true),
+    MEDIO("medio", 34, true),
+    DIFICIL("difícil", 30, false),
+    EXPERTO("experto", 23, false),
 }
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
index 109e6cc..5215f05 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -118,12 +118,15 @@ private fun Tablero(vm: NonetViewModel) {
     val conflictos = vm.conflictos()
     val errores = vm.errores
 
-    // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas de jaula).
+    // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas, pistas de borde).
     val paint = remember {
         Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
     }
+    // Las variantes con pistas externas reservan un margen alrededor del tablero.
+    val mg = if (p.borde != null) 24.dp else 0.dp
 
-    Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
+    Box(Modifier.fillMaxWidth().aspectRatio(1f)) {
+      Box(Modifier.fillMaxSize().padding(mg).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
         Column(Modifier.fillMaxSize()) {
             for (r in 0 until 9) {
                 Row(Modifier.fillMaxWidth().weight(1f)) {
@@ -245,6 +248,57 @@ private fun Tablero(vm: NonetViewModel) {
                 }
             }
         }
+      }
+      // Marco de pistas externas: rascacielos (números), little killer y rossini (flechas).
+      if (p.borde != null) {
+          Canvas(Modifier.fillMaxSize()) {
+              val m = mg.toPx()
+              val inner = size.width - 2 * m
+              val cell = inner / 9f
+              fun gx(c: Int) = m + (c + 0.5f) * cell
+              fun gy(r: Int) = m + (r + 0.5f) * cell
+              val b = p.borde ?: return@Canvas
+              b.sky?.let { sky ->
+                  paint.color = NeonCian.toArgb(); paint.textSize = cell * 0.40f
+                  for (i in 0 until 9) {
+                      if (sky[0][i] > 0) glifo(this, paint, "${sky[0][i]}", gx(i), m * 0.5f)
+                      if (sky[1][i] > 0) glifo(this, paint, "${sky[1][i]}", gx(i), size.height - m * 0.5f)
+                      if (sky[2][i] > 0) glifo(this, paint, "${sky[2][i]}", m * 0.5f, gy(i))
+                      if (sky[3][i] > 0) glifo(this, paint, "${sky[3][i]}", size.width - m * 0.5f, gy(i))
+                  }
+              }
+              b.rossini?.let { ros ->
+                  paint.color = NeonVerde.toArgb(); paint.textSize = cell * 0.5f
+                  for (i in 0 until 9) {
+                      flecha(ros[0][i], 0)?.let { glifo(this, paint, it, gx(i), m * 0.5f) }
+                      flecha(ros[1][i], 1)?.let { glifo(this, paint, it, gx(i), size.height - m * 0.5f) }
+                      flecha(ros[2][i], 2)?.let { glifo(this, paint, it, m * 0.5f, gy(i)) }
+                      flecha(ros[3][i], 3)?.let { glifo(this, paint, it, size.width - m * 0.5f, gy(i)) }
+                  }
+              }
+              if (b.littleKiller.isNotEmpty()) {
+                  paint.color = NeonAmbar.toArgb()
+                  for (lk in b.littleKiller) {
+                      paint.textSize = cell * 0.5f
+                      glifo(this, paint, if (lk.dir == 0) "↘" else "↗", gx(lk.col), m * 0.34f)
+                      paint.textSize = cell * 0.30f
+                      glifo(this, paint, "${lk.sum}", gx(lk.col), m * 0.74f)
+                  }
+              }
+          }
+      }
+    }
+}
+
+/** Flecha de Rossini para un lado (0=arr,1=ab,2=izq,3=der): apunta hacia el orden creciente. */
+private fun flecha(estado: Int, lado: Int): String? {
+    if (estado == 0) return null
+    val creciente = estado == 1
+    return when (lado) {
+        0 -> if (creciente) "↓" else "↑"
+        1 -> if (creciente) "↑" else "↓"
+        2 -> if (creciente) "→" else "←"
+        else -> if (creciente) "←" else "→"
     }
 }
 
diff --git a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
index 0cefdf5..165bb91 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
@@ -1,9 +1,11 @@
 package com.rizoma.nonet.ui
 
+import android.graphics.Paint
+import android.graphics.Typeface
+import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -16,27 +18,38 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.systemBars
-import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import com.rizoma.nonet.NonetViewModel
+import com.rizoma.nonet.game.Motor
 import com.rizoma.nonet.model.Dificultad
 import com.rizoma.nonet.model.Variant
 import com.rizoma.nonet.ui.theme.MuyTenue
 import com.rizoma.nonet.ui.theme.NeonAmbar
 import com.rizoma.nonet.ui.theme.NeonCian
+import com.rizoma.nonet.ui.theme.NeonRosa
 import com.rizoma.nonet.ui.theme.NeonVerde
 import com.rizoma.nonet.ui.theme.SuperficieAlta
 import com.rizoma.nonet.ui.theme.Tenue
@@ -44,95 +57,214 @@ import com.rizoma.nonet.ui.theme.Vacio
 
 @Composable
 fun MenuScreen(vm: NonetViewModel) {
+    val variantes = remember { Variant.entries }
+    val pager = rememberPagerState(initialPage = variantes.indexOf(vm.variante)) { variantes.size }
+    // La página visible fija la variante activa (para "nuevo" y la lista).
+    LaunchedEffect(pager.currentPage) { vm.elegirVariante(variantes[pager.currentPage]) }
+
     Column(
-        Modifier
-            .fillMaxSize()
-            .background(Vacio)
-            .windowInsetsPadding(WindowInsets.systemBars)
-            .verticalScroll(rememberScrollState())
-            .padding(horizontal = 22.dp),
+        Modifier.fillMaxSize().background(Vacio).windowInsetsPadding(WindowInsets.systemBars),
     ) {
-        Spacer(Modifier.height(28.dp))
-        Text("nonet", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
-        Text("sudoku por variante y dificultad", color = MuyTenue, fontSize = 12.sp)
-        Spacer(Modifier.height(22.dp))
-
-        // Selector de variante (scroll horizontal de chips).
-        Text("VARIANTE", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
-        Spacer(Modifier.height(8.dp))
+        Spacer(Modifier.height(20.dp))
+        Row(Modifier.fillMaxWidth().padding(horizontal = 22.dp), verticalAlignment = Alignment.CenterVertically) {
+            Column {
+                Text("nonet", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
+                Text("desliza para ver cada variante", color = MuyTenue, fontSize = 12.sp)
+            }
+            Spacer(Modifier.weight(1f))
+            Text("${pager.currentPage + 1}/${variantes.size}", color = MuyTenue, fontSize = 13.sp)
+        }
+        // Puntos de página.
         Row(
-            Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 10.dp),
+            horizontalArrangement = Arrangement.spacedBy(5.dp),
         ) {
-            Variant.entries.forEach { v ->
-                Chip(v.titulo, selected = v == vm.variante, accent = NeonCian) { vm.elegirVariante(v) }
+            variantes.indices.forEach { idx ->
+                val on = idx == pager.currentPage
+                Box(Modifier.size(if (on) 8.dp else 6.dp).clip(CircleShape).background(if (on) NeonCian else MuyTenue))
             }
         }
-        Spacer(Modifier.height(8.dp))
-        Text(vm.variante.regla, color = Tenue, fontSize = 12.sp)
 
-        Spacer(Modifier.height(20.dp))
+        HorizontalPager(state = pager, modifier = Modifier.weight(1f)) { page ->
+            Pagina(vm, variantes[page])
+        }
+    }
+}
 
-        // Selector de dificultad.
+@Composable
+private fun Pagina(vm: NonetViewModel, v: Variant) {
+    Column(
+        Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
+    ) {
+        Spacer(Modifier.height(8.dp))
+        Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+            FormaVariante(v, Modifier.size(168.dp))
+        }
+        Spacer(Modifier.height(14.dp))
+        Text(v.titulo, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+        Spacer(Modifier.height(4.dp))
+        Text(v.regla, color = Tenue, fontSize = 13.sp)
+
+        Spacer(Modifier.height(18.dp))
         Text("DIFICULTAD", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
         Spacer(Modifier.height(8.dp))
-        Row(
-            Modifier.fillMaxWidth(),
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
-        ) {
+        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
             Dificultad.entries.forEach { d ->
                 Box(Modifier.weight(1f)) {
-                    Chip(d.titulo, selected = d == vm.dificultad, accent = NeonVerde, fill = true) {
-                        vm.elegirDificultad(d)
-                    }
+                    Chip(d.titulo, d == vm.dificultad, NeonVerde, fill = true) { vm.elegirDificultad(d) }
                 }
             }
         }
 
-        Spacer(Modifier.height(24.dp))
-
-        // Puzzles generados de la selección actual.
-        val n = vm.cuenta()
+        Spacer(Modifier.height(20.dp))
+        val n = vm.cuenta(v, vm.dificultad)
         Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
             Text("PUZZLES", color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp)
             Spacer(Modifier.weight(1f))
             Text("$n generado${if (n == 1) "" else "s"}", color = MuyTenue, fontSize = 11.sp)
         }
-        Spacer(Modifier.height(12.dp))
+        Spacer(Modifier.height(10.dp))
 
         (0 until n).toList().chunked(5).forEach { fila ->
             Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                 fila.forEach { idx ->
-                    val id = vm.puzzleId(vm.variante, vm.dificultad, idx)
+                    val id = vm.puzzleId(v, vm.dificultad, idx)
                     val estado = when {
                         id in vm.completados -> NeonVerde
                         id in vm.enCurso -> NeonAmbar
                         else -> MuyTenue
                     }
                     Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
-                        PuzzleNode(idx + 1, estado) { vm.abrir(vm.variante, vm.dificultad, idx) }
+                        PuzzleNode(idx + 1, estado) { vm.abrir(v, vm.dificultad, idx) }
                     }
                 }
                 repeat(5 - fila.size) { Spacer(Modifier.weight(1f)) }
             }
         }
-
-        Spacer(Modifier.height(16.dp))
+        Spacer(Modifier.height(14.dp))
         Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
-            Chip("✦  nuevo puzzle", selected = false, accent = NeonCian) { vm.generarNuevo() }
+            Chip("✦  nuevo puzzle", false, NeonCian) { vm.generarNuevo(v, vm.dificultad) }
         }
         Spacer(Modifier.height(40.dp))
     }
 }
 
+/** Dibujo demostrativo de la forma particular de cada variante. */
+@Composable
+private fun FormaVariante(v: Variant, modifier: Modifier) {
+    val paint = remember {
+        Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
+    }
+    val regionesEj = remember(v) { if (v.tipo == Variant.Tipo.JIGSAW) Motor.jigsawEjemplo() else null }
+    Box(modifier.clip(RoundedCornerShape(10.dp)).background(SuperficieAlta).padding(10.dp)) {
+        Canvas(Modifier.fillMaxSize()) {
+            val cell = size.width / 9f
+            fun cx(c: Int) = (c + 0.5f) * cell
+            fun cy(r: Int) = (r + 0.5f) * cell
+            val amber = NeonAmbar.copy(alpha = 0.30f)
+
+            // Sombreados que van DEBAJO de la rejilla.
+            when (v.realce) {
+                Variant.Realce.CELDAS -> for (i in v.highlightCells()) drawRect(amber, Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+                Variant.Realce.POSICION -> for (i in 0 until 81) {
+                    val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
+                    drawRect(Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.16f), Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+                }
+                Variant.Realce.PARIDAD -> for (i in 0 until 81) if ((i / 9 + i % 9) % 2 == 0)
+                    drawRect(Color.White.copy(alpha = 0.10f), Offset((i % 9) * cell, (i / 9) * cell), androidx.compose.ui.geometry.Size(cell, cell))
+                else -> {}
+            }
+
+            // Rejilla fina.
+            for (k in 0..9) {
+                drawLine(MuyTenue.copy(alpha = 0.5f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
+                drawLine(MuyTenue.copy(alpha = 0.5f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
+            }
+            // Regiones gruesas: jigsaw o cajas 3×3.
+            if (regionesEj != null) {
+                for (r in 0 until 9) for (c in 0 until 9) {
+                    val i = r * 9 + c
+                    if (c < 8 && regionesEj[i] != regionesEj[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+                    if (r < 8 && regionesEj[i] != regionesEj[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+                }
+                drawRect(Tenue, Offset(0f, 0f), androidx.compose.ui.geometry.Size(size.width, size.height), style = Stroke(3f))
+            } else {
+                for (k in 0..9 step 3) {
+                    drawLine(Tenue, Offset(k * cell, 0f), Offset(k * cell, size.height), 3f)
+                    drawLine(Tenue, Offset(0f, k * cell), Offset(size.width, k * cell), 3f)
+                }
+            }
+
+            // Rasgo distintivo encima.
+            when (v) {
+                Variant.X -> {
+                    drawLine(NeonRosa, Offset(0f, 0f), Offset(size.width, size.height), 3f)
+                    drawLine(NeonRosa, Offset(size.width, 0f), Offset(0f, size.height), 3f)
+                }
+                Variant.CONSECUTIVO -> {
+                    drawLine(Color.White, Offset(4 * cell, cy(4) - cell * 0.2f), Offset(4 * cell, cy(4) + cell * 0.2f), 5f)
+                }
+                Variant.XV -> {
+                    paint.textSize = cell * 0.7f; paint.color = NeonCian.toArgb()
+                    texto(this, paint, "X", 4 * cell, cy(3)); texto(this, paint, "V", 6 * cell, cy(5))
+                }
+                Variant.MAYOR_QUE -> {
+                    paint.textSize = cell * 0.8f; paint.color = NeonVerde.toArgb()
+                    texto(this, paint, "‹", 4 * cell, cy(4)); texto(this, paint, "˄", cx(5), 4 * cell)
+                }
+                Variant.KILLER -> {
+                    val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
+                    val ins = cell * 0.12f
+                    // jaula L sobre {(2,2),(2,3),(3,2)}
+                    drawCage(this, listOf(2 to 2, 2 to 3, 3 to 2), cell, ins, dash)
+                    paint.textSize = cell * 0.42f; paint.color = NeonAmbar.toArgb(); paint.textAlign = Paint.Align.LEFT
+                    drawIntoCanvasM(this) { it.drawText("15", 2 * cell + ins + 2f, 2 * cell + ins + cell * 0.4f, paint) }
+                    paint.textAlign = Paint.Align.CENTER
+                }
+                Variant.SKYSCRAPER -> {
+                    paint.textSize = cell * 0.55f; paint.color = NeonCian.toArgb()
+                    texto(this, paint, "2", cx(1), cy(0)); texto(this, paint, "3", cx(4), cy(0)); texto(this, paint, "1", cx(7), cy(0))
+                    texto(this, paint, "2", cx(0), cy(3)); texto(this, paint, "4", cx(0), cy(6))
+                }
+                Variant.LITTLE_KILLER -> {
+                    paint.textSize = cell * 0.6f; paint.color = NeonAmbar.toArgb()
+                    texto(this, paint, "↘", cx(1), cy(0)); paint.textSize = cell * 0.45f
+                    texto(this, paint, "17", cx(1), cy(1))
+                }
+                Variant.ROSSINI -> {
+                    paint.textSize = cell * 0.7f; paint.color = NeonVerde.toArgb()
+                    texto(this, paint, "↓", cx(3), cy(0)); texto(this, paint, "→", cx(0), cy(5))
+                }
+                else -> {}
+            }
+        }
+    }
+}
+
+private fun drawCage(scope: DrawScope, rc: List<Pair<Int, Int>>, cell: Float, ins: Float, dash: PathEffect) {
+    val set = rc.toHashSet()
+    for ((r, c) in rc) {
+        val l = c * cell + ins; val rt = (c + 1) * cell - ins; val t = r * cell + ins; val b = (r + 1) * cell - ins
+        if ((r to (c - 1)) !in set) scope.drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
+        if ((r to (c + 1)) !in set) scope.drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
+        if (((r - 1) to c) !in set) scope.drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
+        if (((r + 1) to c) !in set) scope.drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
+    }
+}
+
+private fun texto(scope: DrawScope, paint: Paint, s: String, x: Float, y: Float) {
+    drawIntoCanvasM(scope) {
+        val dy = (paint.descent() + paint.ascent()) / 2f
+        it.drawText(s, x, y - dy, paint)
+    }
+}
+
+private inline fun drawIntoCanvasM(scope: DrawScope, block: (android.graphics.Canvas) -> Unit) {
+    block(scope.drawContext.canvas.nativeCanvas)
+}
+
 @Composable
-private fun Chip(
-    text: String,
-    selected: Boolean,
-    accent: Color,
-    fill: Boolean = false,
-    onClick: () -> Unit,
-) {
+private fun Chip(text: String, selected: Boolean, accent: Color, fill: Boolean = false, onClick: () -> Unit) {
     Box(
         (if (fill) Modifier.fillMaxWidth() else Modifier)
             .clip(RoundedCornerShape(50))
@@ -143,11 +275,8 @@ private fun Chip(
         contentAlignment = Alignment.Center,
     ) {
         Text(
-            text,
-            color = if (selected) accent else Tenue,
-            fontSize = 14.sp,
-            fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
-            maxLines = 1,
+            text, color = if (selected) accent else Tenue, fontSize = 14.sp,
+            fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1,
         )
     }
 }
@@ -156,12 +285,8 @@ private fun Chip(
 private fun PuzzleNode(numero: Int, accent: Color, onClick: () -> Unit) {
     Column(horizontalAlignment = Alignment.CenterHorizontally) {
         Box(
-            Modifier
-                .size(52.dp)
-                .clip(RoundedCornerShape(14.dp))
-                .background(SuperficieAlta)
-                .border(1.5.dp, accent.copy(alpha = 0.7f), RoundedCornerShape(14.dp))
-                .clickable(onClick = onClick),
+            Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(SuperficieAlta)
+                .border(1.5.dp, accent.copy(alpha = 0.7f), RoundedCornerShape(14.dp)).clickable(onClick = onClick),
             contentAlignment = Alignment.Center,
         ) {
             Text("$numero", color = accent, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)