tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definiciones

Nonet: 6 variantes nuevas (jigsaw, par/impar, consecutivo, XV, mayor-que, killer)

Motor generalizado: regiones irregulares, máscara de paridad por celda,
restricciones binarias entre vecinos y jaulas con suma — todo sobre el mismo
solver/cavado con solución única (verificado para 13 variantes × 4 dificultades).
Teclado reorganizado: las 6 acciones pasan a columnas izquierda (deshacer/
rehacer/comprobar) y derecha (edit/borrar/pista) del 3×3. Overlays por variante:
bordes de región, sombreado par, marcas de arista (barra/X/V/‹›) y jaulas
punteadas con su suma.

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

6d5368c0b298161a61a974eb0c2905c0ec0d183f · por Sergio · (hace 4 meses) · padre b8eadeacd · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

3 archivos · +504 −178

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 3b30a20..3468fc8 100644
--- a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -4,108 +4,218 @@ import com.rizoma.nonet.model.Dificultad
 import com.rizoma.nonet.model.Variant
 import java.util.Random
 
+/** Relación entre dos celdas vecinas (consecutivo, sumas XV, desigualdad). */
+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)
+
+/** Marca a dibujar entre dos celdas adyacentes [a]-[b] (orientada para < >). */
+class Arista(val a: Int, val b: Int, val rel: Rel)
+
 /**
- * Un puzzle generado: las pistas (givens) y su solución única, más las regiones
- * de la variante (para validar jugadas y resaltar conflictos en la UI).
+ * 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).
  */
+class Reglas(
+    val peers: Array<IntArray>,
+    val allowed: IntArray,
+    val bin: Array<List<BinPeer>>,
+    val cagesOf: Array<List<Cage>>,
+    val cages: List<Cage>,
+)
+
+/** Un puzzle generado: pistas, solución única, reglas y datos para dibujar. */
 class Puzzle(
     val variant: Variant,
     val dificultad: Dificultad,
     val index: Int,
-    val givens: IntArray,    // 81; 0 = celda vacía a rellenar
-    val solution: IntArray,  // 81; solución completa (única)
-    val groups: List<IntArray>,
-    val peers: Array<IntArray>,
+    val givens: IntArray,
+    val solution: IntArray,
+    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)
 ) {
-    /** Clave estable: misma (variante, dificultad, índice) → mismo puzzle siempre. */
+    val peers: Array<IntArray> get() = reglas.peers
     val id: String get() = "${variant.id}:${dificultad.ordinal}:$index"
 }
 
 /**
- * El motor de la familia "región extra". Todo se reduce a un conjunto de
- * **grupos** (cada uno, 9 celdas que llevan 1-9 sin repetir): 27 clásicos +
- * los extra de la variante. Generar = hallar una solución completa aleatoria y
- * luego **cavar** celdas mientras la solución siga siendo única.
+ * 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.
  */
 object Motor {
-    // Bits 1..9 puestos: máscara de "todos los dígitos disponibles".
-    private const val TODOS = 0x3FE
-
-    /** Las 27 regiones clásicas (filas, columnas, cajas) + las extra de [v]. */
-    fun groupsFor(v: Variant): List<IntArray> {
-        val g = ArrayList<IntArray>(36)
-        for (r in 0 until 9) g.add(IntArray(9) { r * 9 + it })
-        for (c in 0 until 9) g.add(IntArray(9) { it * 9 + c })
-        for (b in 0 until 9) {
-            val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
-            g.add(IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) })
-        }
-        g.addAll(v.extraGroups())
-        return g
-    }
+    private const val TODOS = 0x3FE                 // bits 1..9
+    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)
 
-    /** Para cada celda, las que comparten algún grupo (sin incluirse a sí misma). */
-    fun peersOf(groups: List<IntArray>): Array<IntArray> {
-        val sets = Array(81) { HashSet<Int>() }
-        for (grp in groups) for (a in grp) for (b in grp) if (a != b) sets[a].add(b)
-        return Array(81) { sets[it].toIntArray() }
-    }
-
-    /** Genera el puzzle determinista de (variante, dificultad, índice). */
     fun generar(v: Variant, dif: Dificultad, index: Int): Puzzle {
         val rng = Random(seedFor(v, dif, index))
-        val groups = groupsFor(v)
-        val peers = peersOf(groups)
 
+        val regiones = if (v.tipo == Variant.Tipo.JIGSAW) jigsaw(rng) else null
+        val baseGroups = ArrayList<IntArray>(36)
+        for (r in 0 until 9) baseGroups.add(IntArray(9) { r * 9 + it })
+        for (c in 0 until 9) baseGroups.add(IntArray(9) { it * 9 + c })
+        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, peers, rng) // backtracking completo: siempre la halla si existe
+        fillFull(solution, soloPeers(peersOf(baseGroups)), rng)
+
+        val (reglas, aristas) = construir(v, solution, baseGroups, rng)
 
         val givens = solution.copyOf()
-        dig(givens, peers, dif.pistasObjetivo, rng)
+        dig(givens, reglas, dif.pistasObjetivo, rng)
 
-        return Puzzle(v, dif, index, givens, solution, groups, peers)
+        return Puzzle(v, dif, index, givens, solution, reglas, regiones, aristas)
     }
 
-    /** Cuenta soluciones de [grid] hasta [limite] (2 basta para juzgar unicidad). */
-    fun contarSoluciones(grid: IntArray, peers: Array<IntArray>, limite: Int): Int =
-        count(grid.copyOf(), peers, limite)
+    /** Cuenta soluciones de [grid] bajo [reglas] hasta [limite]. */
+    fun contarSoluciones(grid: IntArray, reglas: Reglas, limite: Int): Int =
+        count(grid.copyOf(), reglas, limite)
 
-    // ---- interno ------------------------------------------------------
+    // ---- Construcción de reglas por variante --------------------------
 
-    private fun seedFor(v: Variant, dif: Dificultad, index: Int): Long {
-        val s = "${v.id}|${dif.ordinal}|$index"
-        var h = 1_125_899_906_842_597L // primo grande
-        for (ch in s) h = 31 * h + ch.code
-        return h
+    private fun construir(
+        v: Variant,
+        sol: IntArray,
+        baseGroups: List<IntArray>,
+        rng: Random,
+    ): Pair<Reglas, List<Arista>> {
+        val allowed = IntArray(81) { TODOS }
+        val bin = Array(81) { ArrayList<BinPeer>() }
+        val aristas = ArrayList<Arista>()
+        var cages = emptyList<Cage>()
+        var groups = baseGroups
+
+        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))
+                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 }
+                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.
+                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))
+                } else {
+                    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
+        }
+
+        val peers = peersOf(groups)
+        val cagesOf = Array(81) { ArrayList<Cage>() }
+        for (c in cages) for (i in c.cells) cagesOf[i].add(c)
+        val reglas = Reglas(
+            peers = peers,
+            allowed = allowed,
+            bin = Array(81) { bin[it] },
+            cagesOf = Array(81) { cagesOf[it] },
+            cages = cages,
+        )
+        return reglas to aristas
     }
 
-    private fun usedMask(grid: IntArray, peers: IntArray): Int {
+    // ---- Solver (peers + allowed + binarios + jaulas) -----------------
+
+    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)
+        }
+        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
-        for (p in peers) { val v = grid[p]; if (v != 0) m = m or (1 shl v) }
+        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.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++
+        }
         return m
     }
 
-    // Centinelas de mrv en el índice de celda.
-    private const val RESUELTO = -2   // no quedan celdas vacías
-    private const val SIN_SALIDA = -1 // alguna vacía sin candidatos posibles
+    /** ¿Las jaulas que tocan [c] siguen siendo viables tras colocar un dígito? */
+    private fun cagesOk(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 (suma + faltan > cage.sum) return false
+                var maxAdd = 0; var d = 9
+                repeat(faltan) { maxAdd += d; d-- }
+                if (suma + maxAdd < cage.sum) return false
+            }
+        }
+        return true
+    }
 
-    /** Rellena una solución completa con orden de dígitos aleatorio (MRV). */
-    private fun fillFull(grid: IntArray, peers: Array<IntArray>, rng: Random): Boolean {
-        val (idx, mask) = mrv(grid, peers)
+    private fun fillFull(grid: IntArray, reglas: Reglas, rng: Random): Boolean {
+        val (idx, mask) = mrv(grid, reglas)
         if (idx == RESUELTO) return true
         if (idx == SIN_SALIDA) return false
         val digits = digitsOf(mask).also { shuffle(it, rng) }
         for (d in digits) {
             grid[idx] = d
-            if (fillFull(grid, peers, rng)) return true
+            if (cagesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
+            grid[idx] = 0
         }
-        grid[idx] = 0
         return false
     }
 
-    /** Cuenta soluciones (capado a [limite]) por backtracking con MRV. */
-    private fun count(grid: IntArray, peers: Array<IntArray>, limite: Int): Int {
-        val (idx, mask) = mrv(grid, peers)
+    private fun count(grid: IntArray, reglas: Reglas, limite: Int): Int {
+        val (idx, mask) = mrv(grid, reglas)
         if (idx == RESUELTO) return 1
         if (idx == SIN_SALIDA) return 0
         var total = 0
@@ -113,7 +223,7 @@ object Motor {
         while (d <= 9) {
             if (mask and (1 shl d) != 0) {
                 grid[idx] = d
-                total += count(grid, peers, limite - total)
+                if (cagesOk(grid, idx, reglas)) total += count(grid, reglas, limite - total)
                 grid[idx] = 0
                 if (total >= limite) break
             }
@@ -122,35 +232,137 @@ object Motor {
         return total
     }
 
-    /**
-     * Celda vacía con menos candidatos + su máscara de candidatos. Índice
-     * [RESUELTO] si no quedan vacías, o [SIN_SALIDA] si alguna no tiene candidato.
-     */
-    private fun mrv(grid: IntArray, peers: Array<IntArray>): Pair<Int, Int> {
+    private fun mrv(grid: IntArray, reglas: Reglas): Pair<Int, Int> {
         var bestIdx = RESUELTO; var bestMask = 0; var bestCount = 10
         for (c in 0 until 81) {
             if (grid[c] != 0) continue
-            val cand = TODOS and usedMask(grid, peers[c]).inv()
+            val cand = candMask(grid, c, reglas)
             val cnt = Integer.bitCount(cand)
             if (cnt == 0) return SIN_SALIDA to 0
-            if (cnt < bestCount) {
-                bestCount = cnt; bestIdx = c; bestMask = cand
-                if (cnt == 1) break
-            }
+            if (cnt < bestCount) { bestCount = cnt; bestIdx = c; bestMask = cand; if (cnt == 1) break }
         }
         return bestIdx to bestMask
     }
 
-    /** Quita celdas en orden aleatorio mientras la solución siga siendo única. */
-    private fun dig(grid: IntArray, peers: Array<IntArray>, pistasObjetivo: Int, rng: Random) {
+    private fun dig(grid: IntArray, reglas: Reglas, pistasObjetivo: Int, rng: Random) {
         val order = IntArray(81) { it }.also { shuffle(it, rng) }
         var pistas = 81
         for (c in order) {
             if (pistas <= pistasObjetivo) break
             val saved = grid[c]
             grid[c] = 0
-            if (count(grid.copyOf(), peers, 2) != 1) grid[c] = saved else pistas--
+            if (count(grid.copyOf(), reglas, 2) != 1) grid[c] = saved else pistas--
+        }
+    }
+
+    // ---- Regiones -----------------------------------------------------
+
+    private fun boxes(): List<IntArray> = (0 until 9).map { b ->
+        val r0 = (b / 3) * 3; val c0 = (b % 3) * 3
+        IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
+    }
+
+    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 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
+            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
+                }
+                if (elegida == -1) { atascado = true; break }
+                val libres = vecinosLibres(reg, elegida)
+                val cell = libres[rng.nextInt(libres.size)]
+                reg[cell] = 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
+    }
+
+    private fun tieneVecinoLibre(reg: IntArray, id: Int): Boolean {
+        for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) return true
+        return false
+    }
+
+    private fun vecinosLibres(reg: IntArray, id: Int): IntArray {
+        val s = HashSet<Int>()
+        for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) s.add(n)
+        return s.toIntArray()
+    }
+
+    // ---- Jaulas (Killer) ----------------------------------------------
+
+    private fun jaulas(sol: IntArray, rng: Random): List<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 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] }))
         }
+        return cages
+    }
+
+    // ---- Utilidades ---------------------------------------------------
+
+    private fun peersOf(groups: List<IntArray>): Array<IntArray> {
+        val sets = Array(81) { HashSet<Int>() }
+        for (grp in groups) for (a in grp) for (b in grp) if (a != b) sets[a].add(b)
+        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())
+
+    /** 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)
+        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) }
+        }
+    }
+
+    private fun seedFor(v: Variant, dif: Dificultad, index: Int): Long {
+        val s = "${v.id}|${dif.ordinal}|$index"
+        var h = 1_125_899_906_842_597L
+        for (ch in s) h = 31 * h + ch.code
+        return h
     }
 
     private fun digitsOf(mask: Int): IntArray {
@@ -161,9 +373,10 @@ object Motor {
     }
 
     private fun shuffle(a: IntArray, rng: Random) {
-        for (i in a.indices.reversed()) {
-            val j = rng.nextInt(i + 1)
-            val t = a[i]; a[i] = a[j]; a[j] = t
-        }
+        for (i in a.indices.reversed()) { val j = rng.nextInt(i + 1); val t = a[i]; a[i] = a[j]; a[j] = t }
+    }
+
+    private fun shuffle2(a: MutableList<Int>, rng: Random) {
+        for (i in a.indices.reversed()) { val j = rng.nextInt(i + 1); val t = a[i]; a[i] = a[j]; a[j] = t }
     }
 }
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 723aa65..f7e72c8 100644
--- a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -1,102 +1,123 @@
 package com.rizoma.nonet.model
 
 /**
- * Las variantes de la "familia de región extra": todas son Sudoku clásico (filas,
- * columnas y cajas 3×3 con 1-9) MÁS cero o más **grupos extra** —conjuntos de 9
- * celdas que también deben contener cada dígito una sola vez—. Como el único
- * cambio entre ellas son esos grupos, comparten un solo generador/solucionador.
+ * Variantes de Sudoku. Todas parten del clásico (filas, columnas y 9 regiones con
+ * 1-9). Según su [tipo], el motor añade: grupos extra de unicidad, regiones
+ * irregulares (jigsaw), una máscara de paridad por celda (par/impar), relaciones
+ * entre celdas vecinas (consecutivo, XV, mayor-que) o jaulas con suma (killer).
  *
- * Catálogo tomado de https://sudoku-puzzles.net/es/variantes-de-sudoku/ (las que
- * se expresan como regiones de unicidad). Índice de celda = fila*9 + columna.
+ * Catálogo de https://sudoku-puzzles.net/es/variantes-de-sudoku/.
+ * Índice de celda = fila*9 + columna.
  */
 enum class Variant(
     val id: String,
     val titulo: String,
     val regla: String,
+    val tipo: Tipo,
     val realce: Realce,
 ) {
     CLASICO(
         "clasico", "Clásico",
         "Cada fila, columna y caja 3×3 contiene los dígitos del 1 al 9 sin repetir.",
-        Realce.NINGUNO,
+        Tipo.REGION, Realce.NINGUNO,
     ),
     X(
         "x", "Sudoku X",
         "Además del clásico, las dos diagonales principales también llevan 1-9 sin repetir.",
-        Realce.DIAGONALES,
+        Tipo.REGION, Realce.DIAGONALES,
     ),
     WINDOKU(
         "windoku", "Windoku",
         "Cuatro cajas 3×3 sombreadas (las 'ventanas') también deben contener 1-9 sin repetir.",
-        Realce.CELDAS,
+        Tipo.REGION, Realce.CELDAS,
     ),
     DG(
         "dg", "Grupos disjuntos",
         "Las nueve celdas que ocupan la misma posición dentro de su caja forman un grupo 1-9.",
-        Realce.POSICION,
+        Tipo.REGION, Realce.POSICION,
     ),
     PUNTO_CENTRAL(
         "centro", "Punto central",
         "Las nueve celdas centrales de cada caja forman un grupo 1-9 adicional.",
-        Realce.CELDAS,
+        Tipo.REGION, Realce.CELDAS,
     ),
     ASTERISCO(
         "asterisco", "Asterisco",
         "Nueve celdas en patrón de estrella alrededor del centro forman un grupo 1-9.",
-        Realce.CELDAS,
+        Tipo.REGION, Realce.CELDAS,
     ),
     GIRANDOLA(
         "girandola", "Girándola",
         "Nueve celdas en molinillo (pinwheel) a través del centro forman un grupo 1-9.",
-        Realce.CELDAS,
+        Tipo.REGION, Realce.CELDAS,
+    ),
+    JIGSAW(
+        "jigsaw", "Jigsaw",
+        "Las cajas no son 3×3: son nueve regiones irregulares, cada una con 1-9.",
+        Tipo.JIGSAW, Realce.REGIONES,
+    ),
+    PAR_IMPAR(
+        "parimpar", "Par / impar",
+        "Las casillas sombreadas (cuadradas) llevan un dígito par; las demás, impar.",
+        Tipo.PARIDAD, Realce.PARIDAD,
+    ),
+    CONSECUTIVO(
+        "consecutivo", "Consecutivo",
+        "Una barra entre dos casillas indica dígitos consecutivos; sin barra, no lo son.",
+        Tipo.CONSEC, Realce.ARISTAS,
+    ),
+    XV(
+        "xv", "Sudoku XV",
+        "Una X entre dos casillas indica que suman 10; una V, que suman 5; sin marca, ninguno.",
+        Tipo.XV, Realce.ARISTAS,
+    ),
+    MAYOR_QUE(
+        "mayor", "Mayor que",
+        "Los signos < y > entre casillas vecinas de una caja indican cuál dígito es mayor.",
+        Tipo.MAYOR, Realce.ARISTAS,
+    ),
+    KILLER(
+        "killer", "Killer",
+        "Cada jaula punteada suma el número indicado y no repite dígitos.",
+        Tipo.KILLER, Realce.JAULAS,
     );
 
+    /** Familia de la variante: determina qué restricciones añade el motor. */
+    enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER }
+
     /** Cómo se dibuja la restricción extra en el tablero. */
-    enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION }
+    enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS }
 
     /**
-     * Grupos extra (cada uno, 9 índices de celda) que se suman a las 27 regiones
-     * clásicas. La familia entera vive aquí: añadir otra variante de este tipo es
-     * añadir una entrada al enum y su lista de grupos.
+     * Grupos extra (cada uno, 9 índices) de las variantes de "región extra".
+     * Las demás familias derivan sus restricciones de la solución (en el motor).
      */
     fun extraGroups(): List<IntArray> = when (this) {
-        CLASICO -> emptyList()
         X -> listOf(
-            IntArray(9) { it * 9 + it },        // diagonal principal
-            IntArray(9) { it * 9 + (8 - it) },  // diagonal secundaria
+            IntArray(9) { it * 9 + it },
+            IntArray(9) { it * 9 + (8 - it) },
         )
         WINDOKU -> WINDOWS.map { (r0, c0) -> box3x3(r0, c0) }
-        DG -> (0 until 9).map { p ->            // p = posición dentro de la caja
+        DG -> (0 until 9).map { p ->
             val pr = p / 3; val pc = p % 3
-            IntArray(9) { b ->                  // b = índice de caja
-                val br = b / 3; val bc = b % 3
-                (br * 3 + pr) * 9 + (bc * 3 + pc)
-            }
+            IntArray(9) { b -> ((b / 3) * 3 + pr) * 9 + ((b % 3) * 3 + pc) }
         }
-        PUNTO_CENTRAL -> listOf(
-            IntArray(9) { b -> (((b / 3) * 3 + 1) * 9) + ((b % 3) * 3 + 1) },
-        )
+        PUNTO_CENTRAL -> listOf(IntArray(9) { b -> (((b / 3) * 3 + 1) * 9) + ((b % 3) * 3 + 1) })
         ASTERISCO -> listOf(cells(ASTERISK_RC))
         GIRANDOLA -> listOf(cells(GIRANDOLA_RC))
+        else -> emptyList()
     }
 
-    /** Las celdas resaltadas (unión de los grupos extra), para sombrearlas. */
+    /** Celdas resaltadas (unión de grupos extra) para las variantes de tipo CELDAS. */
     fun highlightCells(): Set<Int> =
         if (realce == Realce.CELDAS) extraGroups().flatMap { it.asList() }.toSet() else emptySet()
 
     private companion object {
-        // Esquinas (fila, col) de las 4 ventanas de Windoku.
         val WINDOWS = listOf(1 to 1, 1 to 5, 5 to 1, 5 to 5)
-
-        fun box3x3(r0: Int, c0: Int): IntArray =
-            IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
-
-        // Patrones canónicos (fila, col), 0-indexados.
+        fun box3x3(r0: Int, c0: Int): IntArray = IntArray(9) { i -> (r0 + i / 3) * 9 + (c0 + i % 3) }
         val ASTERISK_RC = listOf(1 to 4, 2 to 2, 2 to 6, 4 to 1, 4 to 4, 4 to 7, 6 to 2, 6 to 6, 7 to 4)
         val GIRANDOLA_RC = listOf(0 to 4, 1 to 1, 1 to 7, 4 to 0, 4 to 4, 4 to 8, 7 to 1, 7 to 7, 8 to 4)
-
-        fun cells(rc: List<Pair<Int, Int>>): IntArray =
-            IntArray(rc.size) { rc[it].first * 9 + rc[it].second }
+        fun cells(rc: List<Pair<Int, Int>>): IntArray = IntArray(rc.size) { rc[it].first * 9 + rc[it].second }
     }
 }
 
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 e13e862..109e6cc 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -1,5 +1,7 @@
 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
@@ -30,10 +32,15 @@ import androidx.compose.ui.draw.alpha
 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.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.Puzzle
+import com.rizoma.nonet.game.Rel
 import com.rizoma.nonet.model.Variant
 import com.rizoma.nonet.ui.theme.MuyTenue
 import com.rizoma.nonet.ui.theme.NeonAmbar
@@ -45,6 +52,8 @@ import com.rizoma.nonet.ui.theme.SuperficieAlta
 import com.rizoma.nonet.ui.theme.Tenue
 import com.rizoma.nonet.ui.theme.Vacio
 
+private const val PARES = (1 shl 2) or (1 shl 4) or (1 shl 6) or (1 shl 8)
+
 @Composable
 fun BoardScreen(vm: NonetViewModel) {
     Column(
@@ -55,13 +64,10 @@ fun BoardScreen(vm: NonetViewModel) {
             .verticalScroll(rememberScrollState())
             .padding(14.dp),
     ) {
-        // Barra superior.
         Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
             Text(
                 "‹  menú",
-                color = Tenue,
-                fontSize = 14.sp,
-                fontWeight = FontWeight.Medium,
+                color = Tenue, fontSize = 14.sp, fontWeight = FontWeight.Medium,
                 modifier = Modifier.clip(RoundedCornerShape(50)).clickable { vm.volverAlMenu() }
                     .padding(vertical = 6.dp, horizontal = 2.dp),
             )
@@ -103,14 +109,20 @@ fun BoardScreen(vm: NonetViewModel) {
 
 @Composable
 private fun Tablero(vm: NonetViewModel) {
+    val p = vm.puzzle ?: return
     val variante = vm.variante
     val sombreadas = remember(variante) { variante.highlightCells() }
     val activo = vm.activo
     val foco = vm.celdaFoco
-    val relacionadas = if (foco in 0..80) vm.puzzle?.peers?.get(foco)?.toHashSet() ?: emptySet() else emptySet<Int>()
+    val relacionadas = if (foco in 0..80) p.peers[foco].toHashSet() else emptySet<Int>()
     val conflictos = vm.conflictos()
     val errores = vm.errores
 
+    // Paint reutilizable para los glifos del Canvas (X, V, < >, sumas de jaula).
+    val paint = remember {
+        Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER }
+    }
+
     Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Superficie)) {
         Column(Modifier.fillMaxSize()) {
             for (r in 0 until 9) {
@@ -127,31 +139,130 @@ private fun Tablero(vm: NonetViewModel) {
                             relacionada = i in relacionadas,
                             mismoDigito = activo in 1..9 && vm.entradas[i] == activo,
                             errorRojo = i in conflictos || i in errores,
-                            sombra = sombraDe(i, variante, sombreadas),
+                            sombra = sombraDe(i, p, variante, sombreadas),
                             onClick = { vm.tocarCelda(i) },
                         )
                     }
                 }
             }
         }
-        // Líneas de la rejilla (+ diagonales del Sudoku X) por encima.
+        // Overlay: líneas de regiones/cajas, diagonales, marcas de aristas y jaulas.
         Canvas(Modifier.fillMaxSize()) {
             val cell = size.width / 9f
+            fun cx(c: Int) = (c + 0.5f) * cell
+            fun cy(r: Int) = (r + 0.5f) * cell
+
             if (variante == Variant.X) {
                 drawLine(NeonRosa.copy(alpha = 0.30f), Offset(0f, 0f), Offset(size.width, size.height), 3f)
                 drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, size.height), 3f)
             }
+
+            // Líneas finas de todas las celdas.
             for (k in 0..9) {
-                val gruesa = k % 3 == 0
-                val color = if (gruesa) Tenue else MuyTenue.copy(alpha = 0.4f)
-                val w = if (gruesa) 3f else 1f
-                drawLine(color, Offset(k * cell, 0f), Offset(k * cell, size.height), w)
-                drawLine(color, Offset(0f, k * cell), Offset(size.width, k * cell), w)
+                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(k * cell, 0f), Offset(k * cell, size.height), 1f)
+                drawLine(MuyTenue.copy(alpha = 0.4f), Offset(0f, k * cell), Offset(size.width, k * cell), 1f)
+            }
+            // Bordes gruesos: regiones del jigsaw, o cajas 3×3.
+            val reg = p.regiones
+            if (reg != null) {
+                for (r in 0 until 9) for (c in 0 until 9) {
+                    val i = r * 9 + c
+                    if (c < 8 && reg[i] != reg[i + 1]) drawLine(Tenue, Offset((c + 1) * cell, r * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+                    if (r < 8 && reg[i] != reg[i + 9]) drawLine(Tenue, Offset(c * cell, (r + 1) * cell), Offset((c + 1) * cell, (r + 1) * cell), 3f)
+                }
+                drawLine(Tenue, Offset(0f, 0f), Offset(size.width, 0f), 3f)
+                drawLine(Tenue, Offset(0f, size.height), Offset(size.width, size.height), 3f)
+                drawLine(Tenue, Offset(0f, 0f), Offset(0f, size.height), 3f)
+                drawLine(Tenue, Offset(size.width, 0f), Offset(size.width, size.height), 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)
+                }
+            }
+
+            // Jaulas (Killer): borde punteado + suma en la celda superior izquierda.
+            if (p.reglas.cages.isNotEmpty()) {
+                val cageId = IntArray(81) { -1 }
+                p.reglas.cages.forEachIndexed { id, cg -> for (cl in cg.cells) cageId[cl] = id }
+                val dash = PathEffect.dashPathEffect(floatArrayOf(5f, 5f))
+                val ins = cell * 0.10f
+                for (rr in 0 until 9) for (cc in 0 until 9) {
+                    val i = rr * 9 + cc
+                    val id = cageId[i]
+                    val l = cc * cell + ins; val rt = (cc + 1) * cell - ins
+                    val t = rr * cell + ins; val b = (rr + 1) * cell - ins
+                    if (cc == 0 || cageId[i - 1] != id) drawLine(NeonAmbar, Offset(l, t), Offset(l, b), 1.5f, pathEffect = dash)
+                    if (cc == 8 || cageId[i + 1] != id) drawLine(NeonAmbar, Offset(rt, t), Offset(rt, b), 1.5f, pathEffect = dash)
+                    if (rr == 0 || cageId[i - 9] != id) drawLine(NeonAmbar, Offset(l, t), Offset(rt, t), 1.5f, pathEffect = dash)
+                    if (rr == 8 || cageId[i + 9] != id) drawLine(NeonAmbar, Offset(l, b), Offset(rt, b), 1.5f, pathEffect = dash)
+                }
+                paint.color = NeonAmbar.toArgb()
+                paint.textAlign = Paint.Align.LEFT
+                paint.textSize = cell * 0.26f
+                for (cg in p.reglas.cages) {
+                    val top = cg.cells.min()
+                    drawIntoCanvas2(this) { nc ->
+                        nc.drawText("${cg.sum}", (top % 9) * cell + ins + 2f, (top / 9) * cell + ins + cell * 0.24f, paint)
+                    }
+                }
+                paint.textAlign = Paint.Align.CENTER
+            }
+
+            // Marcas de aristas (consecutivo / XV / mayor-que).
+            if (p.aristas.isNotEmpty()) {
+                paint.textSize = cell * 0.42f
+                for (a in p.aristas) {
+                    val ra = a.a / 9; val ca = a.a % 9
+                    val horizontal = a.b == a.a + 1
+                    val mx = if (horizontal) (ca + 1) * cell else cx(ca)
+                    val my = if (horizontal) cy(ra) else (ra + 1) * cell
+                    when (a.rel) {
+                        Rel.CONSEC -> {
+                            // Barra blanca perpendicular a la arista.
+                            val h = cell * 0.16f
+                            if (horizontal) drawLine(Color.White, Offset(mx, my - h), Offset(mx, my + h), 4f)
+                            else drawLine(Color.White, Offset(mx - h, my), Offset(mx + h, my), 4f)
+                        }
+                        Rel.SUM5, Rel.SUM10 -> {
+                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
+                            paint.color = NeonCian.toArgb()
+                            glifo(this, paint, if (a.rel == Rel.SUM10) "X" else "V", mx, my)
+                        }
+                        Rel.LT, Rel.GT -> {
+                            drawCircle(Vacio, cell * 0.16f, Offset(mx, my))
+                            paint.color = NeonVerde.toArgb()
+                            val s = when {
+                                horizontal && a.rel == Rel.LT -> "‹"
+                                horizontal -> "›"
+                                a.rel == Rel.LT -> "˄"
+                                else -> "˅"
+                            }
+                            glifo(this, paint, s, mx, my)
+                        }
+                        else -> {}
+                    }
+                }
             }
         }
     }
 }
 
+/** Dibuja [s] centrado verticalmente en (x,y) con el [paint] dado. */
+private fun glifo(scope: androidx.compose.ui.graphics.drawscope.DrawScope, paint: Paint, s: String, x: Float, y: Float) {
+    drawIntoCanvas2(scope) { nc ->
+        val dy = (paint.descent() + paint.ascent()) / 2f
+        nc.drawText(s, x, y - dy, paint)
+    }
+}
+
+private inline fun drawIntoCanvas2(
+    scope: androidx.compose.ui.graphics.drawscope.DrawScope,
+    block: (android.graphics.Canvas) -> Unit,
+) {
+    block(scope.drawContext.canvas.nativeCanvas)
+}
+
 @Composable
 private fun Celda(
     modifier: Modifier,
@@ -176,11 +287,7 @@ private fun Celda(
         when {
             valor != 0 -> Text(
                 "$valor",
-                color = when {
-                    errorRojo -> NeonRosa
-                    fija -> Color.White
-                    else -> NeonCian
-                },
+                color = when { errorRojo -> NeonRosa; fija -> Color.White; else -> NeonCian },
                 fontSize = 18.sp,
                 fontWeight = if (fija) FontWeight.Bold else FontWeight.Medium,
             )
@@ -189,7 +296,6 @@ private fun Celda(
     }
 }
 
-/** Mini 3×3 de notas a lápiz; la nota del dígito activo se realza. */
 @Composable
 private fun NotasMini(notas: Int, activo: Int) {
     Column(Modifier.fillMaxSize().padding(1.dp)) {
@@ -213,50 +319,46 @@ private fun NotasMini(notas: Int, activo: Int) {
     }
 }
 
-private fun sombraDe(i: Int, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
+private fun sombraDe(i: Int, p: Puzzle, v: Variant, sombreadas: Set<Int>): Color = when (v.realce) {
     Variant.Realce.CELDAS -> if (i in sombreadas) NeonAmbar.copy(alpha = 0.13f) else Color.Transparent
     Variant.Realce.POSICION -> {
-        // Tinte tenue por posición dentro de la caja (los 9 grupos disjuntos).
-        val p = ((i / 9) % 3) * 3 + (i % 9) % 3
-        Color.hsv(p * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
+        val pos = ((i / 9) % 3) * 3 + (i % 9) % 3
+        Color.hsv(pos * 40f, 0.55f, 0.9f).copy(alpha = 0.10f)
     }
+    // Par/impar: las celdas "par" (cuadradas) van sombreadas; las impar, limpias.
+    Variant.Realce.PARIDAD -> if (p.reglas.allowed[i] == PARES) Color.White.copy(alpha = 0.08f) else Color.Transparent
     else -> Color.Transparent
 }
 
 @Composable
 private fun Teclado(vm: NonetViewModel) {
-    // Cuántos de cada dígito quedan por colocar (9 menos los ya puestos).
     val restantes = IntArray(10)
     for (v in vm.entradas) if (v in 1..9) restantes[v]++
 
-    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
-        // Fila superior: deshacer, rehacer, comprobar.
-        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
-            Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer, Modifier.weight(1f)) { vm.deshacer() }
-            Accion("↷", "rehacer", NeonCian, vm.puedeRehacer, Modifier.weight(1f)) { vm.rehacerJugada() }
-            Accion("♥", "comprobar", NeonVerde, !vm.completado, Modifier.weight(1f)) { vm.comprobar() }
+    // Columnas: [undo/redo/comprobar]  ·  3×3 números  ·  [edit/borrar/pista]
+    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+            Accion("↶", "deshacer", NeonCian, vm.puedeDeshacer) { vm.deshacer() }
+            Accion("↷", "rehacer", NeonCian, vm.puedeRehacer) { vm.rehacerJugada() }
+            Accion("♥", "comprobar", NeonVerde, !vm.completado) { vm.comprobar() }
         }
-        // Numérico 3×3.
-        for (fila in 0 until 3) {
-            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
-                for (col in 0 until 3) {
-                    val d = fila * 3 + col + 1
-                    DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
+        Column(Modifier.weight(3f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+            for (fila in 0 until 3) {
+                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+                    for (col in 0 until 3) {
+                        val d = fila * 3 + col + 1
+                        DigitoKey(d, 9 - restantes[d], vm.activo == d, Modifier.weight(1f)) { vm.elegirActivo(d) }
+                    }
                 }
             }
         }
-        // Fila inferior: edit (borrador), borrar, pista.
-        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
             Accion(
                 "✎", if (vm.modoEdicion) "edit ✓" else "edit",
-                if (vm.modoEdicion) NeonAmbar else Tenue, true, Modifier.weight(1f),
-                resaltado = vm.modoEdicion,
+                if (vm.modoEdicion) NeonAmbar else Tenue, true, resaltado = vm.modoEdicion,
             ) { vm.alternarEdicion() }
-            Accion(
-                "⌫", "borrar", NeonRosa, true, Modifier.weight(1f),
-                resaltado = vm.activo == NonetViewModel.BORRAR,
-            ) { vm.activarBorrar() }
-            Accion("◌", "pista", NeonAmbar, !vm.completado, Modifier.weight(1f)) { vm.pista() }
+            Accion("⌫", "borrar", NeonRosa, true, resaltado = vm.activo == NonetViewModel.BORRAR) { vm.activarBorrar() }
+            Accion("◌", "pista", NeonAmbar, !vm.completado) { vm.pista() }
         }
     }
 }
@@ -273,17 +375,9 @@ private fun DigitoKey(d: Int, restantes: Int, activo: Boolean, modifier: Modifie
             .clickable(onClick = onClick),
         contentAlignment = Alignment.Center,
     ) {
+        Text("$d", color = if (agotado) MuyTenue else NeonCian, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
         Text(
-            "$d",
-            color = if (agotado) MuyTenue else NeonCian,
-            fontSize = 22.sp,
-            fontWeight = FontWeight.SemiBold,
-        )
-        // Cuántos quedan, en la esquina inferior.
-        Text(
-            "$restantes",
-            color = MuyTenue,
-            fontSize = 9.sp,
+            "$restantes", color = MuyTenue, fontSize = 9.sp,
             modifier = Modifier.align(Alignment.BottomEnd).padding(end = 5.dp, bottom = 2.dp),
         )
     }
@@ -295,13 +389,13 @@ private fun Accion(
     label: String,
     accent: Color,
     habilitado: Boolean,
-    modifier: Modifier,
     resaltado: Boolean = false,
     onClick: () -> Unit,
 ) {
     Box(
-        modifier
-            .height(46.dp)
+        Modifier
+            .fillMaxWidth()
+            .height(48.dp)
             .clip(RoundedCornerShape(12.dp))
             .background(if (resaltado) accent.copy(alpha = 0.18f) else SuperficieAlta)
             .border(1.dp, if (resaltado) accent else Color.Transparent, RoundedCornerShape(12.dp))
@@ -311,10 +405,8 @@ private fun Accion(
     ) {
         Text(icono, color = accent, fontSize = 16.sp)
         Text(
-            label,
-            color = accent,
-            fontSize = 9.sp,
-            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 3.dp),
+            label, color = accent, fontSize = 8.sp,
+            modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
         )
     }
 }