Nonet: variantes Argyle y Cadenas + arreglo de generación de regiones
Dos variantes nuevas (18 en total): Argyle (clásico + diagonales en rombo que no repiten 1-9) y Cadenas/Chain (cajas sustituidas por cadenas serpenteantes de 9 celdas, vía camino hamiltoniano "backbite"). Arreglo importante de generación de regiones: el solver ahora lleva presupuesto de nodos, así una distribución irresoluble se descarta en vez de colgar el backtracking (antes Chain podía colgar la generación). Y el generador jigsaw —que se atascaba y degeneraba silenciosamente en cajas 3×3— se rehízo con Voronoi (semillas en centros de caja con jitter) + rebalanceo conexo: ahora produce regiones irregulares de verdad y resolubles (~94% irregulares, el resto cae a cajas como respaldo seguro). Verificado: 18 variantes × 4 dificultades, todas válidas, únicas y sin cuelgues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
4 qillqakuna · +178 −35
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 faed257..0cd11b4 100644
--- a/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/game/Motor.kt
@@ -71,30 +71,58 @@ object Motor {
fun generar(v: Variant, dif: Dificultad, index: Int): Puzzle {
val rng = Random(seedFor(v, dif, index))
-
- 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())
-
val solution = IntArray(81)
- fillFull(solution, soloPeers(baseGroups), rng)
+ var regiones: IntArray? = null
+ var baseGroups: List<IntArray> = emptyList()
+
+ if (v.tipo == Variant.Tipo.JIGSAW || v.tipo == Variant.Tipo.CHAIN) {
+ // No toda distribución de regiones admite solución de Sudoku. Probamos
+ // varias (acotando el solver para descartar rápido las irresolubles);
+ // si ninguna sirve, caemos a cajas 3×3 (siempre resoluble).
+ var ok = false
+ for (intento in 0 until 120) {
+ val cand = if (v.tipo == Variant.Tipo.JIGSAW) jigsaw(rng) else chains(rng)
+ val grupos = gruposBase(cand, v)
+ solution.fill(0)
+ // Una distribución resoluble se llena en pocos nodos; una
+ // irresoluble agota el presupuesto y se descarta sin colgarse.
+ if (fillFull(solution, soloPeers(grupos), rng, intArrayOf(80_000))) {
+ regiones = cand; baseGroups = grupos; ok = true; break
+ }
+ }
+ if (!ok) {
+ regiones = IntArray(81) { (it / 9 / 3) * 3 + (it % 9 / 3) }
+ baseGroups = gruposBase(regiones, v)
+ solution.fill(0)
+ fillFull(solution, soloPeers(baseGroups), rng, intArrayOf(Int.MAX_VALUE))
+ }
+ } else {
+ baseGroups = gruposBase(null, v)
+ fillFull(solution, soloPeers(baseGroups), rng, intArrayOf(Int.MAX_VALUE))
+ }
val construido = construir(v, solution, baseGroups, rng)
-
val givens = solution.copyOf()
dig(givens, construido.reglas, dif.pistasObjetivo, dif.soloLogica, rng)
return Puzzle(v, dif, index, givens, solution, construido.reglas, regiones, construido.aristas, construido.borde)
}
+ private fun gruposBase(regiones: IntArray?, 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 })
+ g.addAll(regiones?.let { regionGroups(it) } ?: boxes())
+ g.addAll(v.extraGroups())
+ return g
+ }
+
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ú). */
+ /** Distribuciones fijas (para ilustrar las variantes de región en el menú). */
fun jigsawEjemplo(): IntArray = jigsaw(Random(7))
+ fun chainEjemplo(): IntArray = chains(Random(11))
// ---- Construcción de reglas por variante --------------------------
@@ -284,14 +312,17 @@ object Motor {
return true
}
- private fun fillFull(grid: IntArray, reglas: Reglas, rng: Random): Boolean {
+ /** Rellena una solución; [budget] acota los nodos para no atascarse en distribuciones irresolubles. */
+ private fun fillFull(grid: IntArray, reglas: Reglas, rng: Random, budget: IntArray): Boolean {
+ if (budget[0] <= 0) return false
+ budget[0]--
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 (restriccionesOk(grid, idx, reglas) && fillFull(grid, reglas, rng)) return true
+ if (restriccionesOk(grid, idx, reglas) && fillFull(grid, reglas, rng, budget)) return true
grid[idx] = 0
}
return false
@@ -374,33 +405,102 @@ object Motor {
private fun regionGroups(reg: IntArray): List<IntArray> =
(0 until 9).map { id -> (0 until 81).filter { reg[it] == id }.toIntArray() }
+ /**
+ * 9 regiones irregulares de 9 celdas. Voronoi por BFS multi-fuente desde 9
+ * semillas (da 9 regiones conexas de tamaño desigual) y luego rebalanceo:
+ * se mueven celdas de frontera de una región grande a una pequeña vecina,
+ * manteniendo la conexión, hasta dejar las nueve con 9 celdas.
+ */
+ // Centros de las 9 cajas: semillas base del Voronoi (con jitter → regiones
+ // compactas pero irregulares, que son mucho más resolubles que las caóticas).
+ private val CENTROS = intArrayOf(10, 13, 16, 37, 40, 43, 64, 67, 70)
+
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) {
- 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)
- reg[libres[rng.nextInt(libres.size)]] = elegida; sizes[elegida]++; faltan--
+ repeat(50) {
+ val reg = IntArray(81) { -1 }
+ val usados = HashSet<Int>(); val seeds = ArrayList<Int>(9)
+ for (cen in CENTROS) {
+ var pick = -1
+ for (t in 0 until 10) {
+ val r = ((cen / 9) + rng.nextInt(3) - 1).coerceIn(0, 8)
+ val c = ((cen % 9) + rng.nextInt(3) - 1).coerceIn(0, 8)
+ val cand = r * 9 + c
+ if (cand !in usados) { pick = cand; break }
+ }
+ if (pick == -1) pick = cen
+ usados.add(pick); seeds.add(pick)
}
- if (!atascado && sizes.all { it == 9 }) return reg
+ val q = ArrayDeque<Int>()
+ seeds.forEachIndexed { id, c -> reg[c] = id; q.addLast(c) }
+ while (q.isNotEmpty()) {
+ val x = q.removeFirst()
+ for (n in ortog(x).toMutableList().also { shuffle2(it, rng) }) if (reg[n] == -1) { reg[n] = reg[x]; q.addLast(n) }
+ }
+ val sizes = IntArray(9); for (i in 0 until 81) sizes[reg[i]]++
+ if (rebalancear(reg, sizes, rng)) return reg
}
return IntArray(81) { (it / 9 / 3) * 3 + (it % 9 / 3) }
}
- 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
+ /** Iguala las regiones a 9 celdas moviendo celdas de frontera (grande→pequeña vecina). */
+ private fun rebalancear(reg: IntArray, sizes: IntArray, rng: Random): Boolean {
+ repeat(8000) {
+ if (sizes.all { it == 9 }) return true
+ val grandes = (0 until 9).filter { sizes[it] > 9 }
+ if (grandes.isEmpty()) return false
+ val a = grandes[rng.nextInt(grandes.size)]
+ val borde = ArrayList<Pair<Int, Int>>()
+ for (i in 0 until 81) if (reg[i] == a) for (n in ortog(i)) { val t = reg[n]; if (t != a && sizes[t] < 9) borde.add(i to t) }
+ if (borde.isEmpty()) return@repeat
+ val (cell, t) = borde[rng.nextInt(borde.size)]
+ reg[cell] = t
+ if (conexa(reg, a)) { sizes[a]--; sizes[t]++ } else reg[cell] = a
+ }
+ return sizes.all { it == 9 }
+ }
+
+ /** ¿Todas las celdas de la región [id] están conectadas ortogonalmente? */
+ private fun conexa(reg: IntArray, id: Int): Boolean {
+ var start = -1; var total = 0
+ for (i in 0 until 81) if (reg[i] == id) { total++; if (start == -1) start = i }
+ if (start == -1) return true
+ val vis = BooleanArray(81); val pila = ArrayDeque<Int>()
+ pila.addLast(start); vis[start] = true; var seen = 0
+ while (pila.isNotEmpty()) {
+ val x = pila.removeLast(); seen++
+ for (n in ortog(x)) if (!vis[n] && reg[n] == id) { vis[n] = true; pila.addLast(n) }
+ }
+ return seen == total
}
- 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()
+ /**
+ * 9 cadenas serpenteantes de 9 celdas. Se parte de un camino hamiltoniano en
+ * serpentina y se aleatoriza con movimientos "backbite" (que lo mantienen
+ * hamiltoniano); luego se corta en 9 segmentos de 9 → cada uno es una cadena
+ * conexa. Siempre tiene éxito (a diferencia de hacer crecer 9 serpientes a la vez).
+ */
+ private fun chains(rng: Random): IntArray {
+ val path = IntArray(81); var idx = 0
+ for (r in 0 until 9) { val cols = if (r % 2 == 0) 0..8 else 8 downTo 0; for (c in cols) path[idx++] = r * 9 + c }
+ val posOf = IntArray(81); for (i in 0 until 81) posOf[path[i]] = i
+ repeat(4000) {
+ val cabeza = rng.nextBoolean()
+ val end = if (cabeza) path[0] else path[80]
+ for (u in ortog(end).toMutableList().also { shuffle2(it, rng) }) {
+ val k = posOf[u]
+ if (cabeza && k >= 2) { reverseTramo(path, 0, k - 1, posOf); break }
+ if (!cabeza && k <= 78) { reverseTramo(path, k + 1, 80, posOf); break }
+ }
+ }
+ return IntArray(81) { posOf[it] / 9 }
+ }
+
+ private fun reverseTramo(path: IntArray, lo: Int, hi: Int, posOf: IntArray) {
+ var i = lo; var j = hi
+ while (i < j) {
+ val t = path[i]; path[i] = path[j]; path[j] = t
+ posOf[path[i]] = i; posOf[path[j]] = j; i++; j--
+ }
}
private fun jaulas(sol: IntArray, rng: Random): List<Cage> {
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 286049f..023775e 100644
--- a/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/model/Variant.kt
@@ -95,13 +95,23 @@ enum class Variant(
"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,
+ ),
+ ARGYLE(
+ "argyle", "Argyle",
+ "Además del clásico, varias diagonales en patrón de rombo (argyle) llevan 1-9 sin repetir.",
+ Tipo.REGION, Realce.ARGYLE,
+ ),
+ CHAIN(
+ "chain", "Cadenas",
+ "Las cajas se sustituyen por cadenas serpenteantes de 9 celdas, cada una con 1-9.",
+ Tipo.CHAIN, Realce.REGIONES,
);
/** Familia de la variante: determina qué restricciones añade el motor. */
- enum class Tipo { REGION, JIGSAW, PARIDAD, CONSEC, XV, MAYOR, KILLER, SKY, LITTLE, ROSSINI }
+ enum class Tipo { REGION, JIGSAW, CHAIN, 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, BORDE }
+ enum class Realce { NINGUNO, DIAGONALES, CELDAS, POSICION, REGIONES, PARIDAD, ARISTAS, JAULAS, BORDE, ARGYLE }
/**
* Grupos extra (cada uno, 9 índices) de las variantes de "región extra".
@@ -120,9 +130,28 @@ enum class Variant(
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))
+ ARGYLE -> argyleDiagonals()
else -> emptyList()
}
+ /**
+ * Las diagonales del patrón argyle, ordenadas a lo largo de cada línea: las
+ * dos diagonales principales (la X completa) más cuatro cortas que forman el
+ * rombo central. Cada una es un grupo de no-repetición (longitud 5 ó 9).
+ */
+ fun argyleDiagonals(): List<IntArray> {
+ val out = ArrayList<IntArray>(6)
+ for (d in intArrayOf(0, 4, -4)) { // ↘: c = r - d
+ val cs = (0..8).filter { it - d in 0..8 }.map { it * 9 + (it - d) }
+ out.add(cs.toIntArray())
+ }
+ for (s in intArrayOf(8, 4, 12)) { // ↗: c = s - r
+ val cs = (0..8).filter { s - it in 0..8 }.map { it * 9 + (s - it) }
+ out.add(cs.toIntArray())
+ }
+ return out
+ }
+
/** 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()
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 5215f05..5bc6ed5 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/BoardScreen.kt
@@ -159,6 +159,10 @@ private fun Tablero(vm: NonetViewModel) {
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)
}
+ if (variante == Variant.ARGYLE) for (g in Variant.ARGYLE.argyleDiagonals()) {
+ val a = g.first(); val z = g.last()
+ drawLine(NeonRosa.copy(alpha = 0.30f), Offset(cx(a % 9), cy(a / 9)), Offset(cx(z % 9), cy(z / 9)), 3f)
+ }
// Líneas finas de todas las celdas.
for (k in 0..9) {
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 165bb91..2ea7f97 100644
--- a/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
+++ b/nonet/src/main/java/com/rizoma/nonet/ui/MenuScreen.kt
@@ -155,7 +155,13 @@ 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 }
+ val regionesEj = remember(v) {
+ when (v.tipo) {
+ Variant.Tipo.JIGSAW -> Motor.jigsawEjemplo()
+ Variant.Tipo.CHAIN -> Motor.chainEjemplo()
+ else -> null
+ }
+ }
Box(modifier.clip(RoundedCornerShape(10.dp)).background(SuperficieAlta).padding(10.dp)) {
Canvas(Modifier.fillMaxSize()) {
val cell = size.width / 9f
@@ -235,6 +241,10 @@ private fun FormaVariante(v: Variant, modifier: Modifier) {
paint.textSize = cell * 0.7f; paint.color = NeonVerde.toArgb()
texto(this, paint, "↓", cx(3), cy(0)); texto(this, paint, "→", cx(0), cy(5))
}
+ Variant.ARGYLE -> for (g in v.argyleDiagonals()) {
+ val a = g.first(); val z = g.last()
+ drawLine(NeonRosa, Offset(cx(a % 9), cy(a / 9)), Offset(cx(z % 9), cy(z / 9)), 2.5f)
+ }
else -> {}
}
}