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>
This commit is contained in:
Sergio
2026-06-19 12:06:55 +00:00
co-authored by Claude Opus 4.8
parent c02fd3a4d4
commit 7f7209e445
4 changed files with 178 additions and 35 deletions
@@ -71,30 +71,58 @@ object Motor {
fun generar(v: Variant, dif: Dificultad, index: Int): Puzzle { fun generar(v: Variant, dif: Dificultad, index: Int): Puzzle {
val rng = Random(seedFor(v, dif, index)) 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) 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 construido = construir(v, solution, baseGroups, rng)
val givens = solution.copyOf() val givens = solution.copyOf()
dig(givens, construido.reglas, dif.pistasObjetivo, dif.soloLogica, rng) dig(givens, construido.reglas, dif.pistasObjetivo, dif.soloLogica, rng)
return Puzzle(v, dif, index, givens, solution, construido.reglas, regiones, construido.aristas, construido.borde) 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 = fun contarSoluciones(grid: IntArray, reglas: Reglas, limite: Int): Int =
count(grid.copyOf(), reglas, limite) 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 jigsawEjemplo(): IntArray = jigsaw(Random(7))
fun chainEjemplo(): IntArray = chains(Random(11))
// ---- Construcción de reglas por variante -------------------------- // ---- Construcción de reglas por variante --------------------------
@@ -284,14 +312,17 @@ object Motor {
return true 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) val (idx, mask) = mrv(grid, reglas)
if (idx == RESUELTO) return true if (idx == RESUELTO) return true
if (idx == SIN_SALIDA) return false if (idx == SIN_SALIDA) return false
val digits = digitsOf(mask).also { shuffle(it, rng) } val digits = digitsOf(mask).also { shuffle(it, rng) }
for (d in digits) { for (d in digits) {
grid[idx] = d 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 grid[idx] = 0
} }
return false return false
@@ -374,33 +405,102 @@ object Motor {
private fun regionGroups(reg: IntArray): List<IntArray> = private fun regionGroups(reg: IntArray): List<IntArray> =
(0 until 9).map { id -> (0 until 81).filter { reg[it] == id }.toIntArray() } (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 { private fun jigsaw(rng: Random): IntArray {
repeat(300) { repeat(50) {
val reg = IntArray(81) { -1 }; val sizes = IntArray(9) val reg = IntArray(81) { -1 }
val seeds = (0 until 81).toMutableList().also { shuffle2(it, rng) }.take(9) val usados = HashSet<Int>(); val seeds = ArrayList<Int>(9)
seeds.forEachIndexed { id, cell -> reg[cell] = id; sizes[id] = 1 } for (cen in CENTROS) {
var faltan = 81 - 9; var atascado = false var pick = -1
while (faltan > 0 && !atascado) { for (t in 0 until 10) {
var elegida = -1; var menor = 10 val r = ((cen / 9) + rng.nextInt(3) - 1).coerceIn(0, 8)
for (id in 0 until 9) if (sizes[id] in 1 until 9 && sizes[id] < menor && tieneVecinoLibre(reg, id)) { menor = sizes[id]; elegida = id } val c = ((cen % 9) + rng.nextInt(3) - 1).coerceIn(0, 8)
if (elegida == -1) { atascado = true; break } val cand = r * 9 + c
val libres = vecinosLibres(reg, elegida) if (cand !in usados) { pick = cand; break }
reg[libres[rng.nextInt(libres.size)]] = elegida; sizes[elegida]++; faltan-- }
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) } return IntArray(81) { (it / 9 / 3) * 3 + (it % 9 / 3) }
} }
private fun tieneVecinoLibre(reg: IntArray, id: Int): Boolean { /** Iguala las regiones a 9 celdas moviendo celdas de frontera (grande→pequeña vecina). */
for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) return true private fun rebalancear(reg: IntArray, sizes: IntArray, rng: Random): Boolean {
return false 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 }
} }
private fun vecinosLibres(reg: IntArray, id: Int): IntArray { /** ¿Todas las celdas de la región [id] están conectadas ortogonalmente? */
val s = HashSet<Int>() private fun conexa(reg: IntArray, id: Int): Boolean {
for (i in 0 until 81) if (reg[i] == id) for (n in ortog(i)) if (reg[n] == -1) s.add(n) var start = -1; var total = 0
return s.toIntArray() 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
}
/**
* 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> { private fun jaulas(sol: IntArray, rng: Random): List<Cage> {
@@ -95,13 +95,23 @@ enum class Variant(
"rossini", "Rossini", "rossini", "Rossini",
"Una flecha en el borde indica que las tres casillas más cercanas van en orden creciente hacia donde apunta.", "Una flecha en el borde indica que las tres casillas más cercanas van en orden creciente hacia donde apunta.",
Tipo.ROSSINI, Realce.BORDE, 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. */ /** 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. */ /** 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". * 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) }) PUNTO_CENTRAL -> listOf(IntArray(9) { b -> (((b / 3) * 3 + 1) * 9) + ((b % 3) * 3 + 1) })
ASTERISCO -> listOf(cells(ASTERISK_RC)) ASTERISCO -> listOf(cells(ASTERISK_RC))
GIRANDOLA -> listOf(cells(GIRANDOLA_RC)) GIRANDOLA -> listOf(cells(GIRANDOLA_RC))
ARGYLE -> argyleDiagonals()
else -> emptyList() 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. */ /** Celdas resaltadas (unión de grupos extra) para las variantes de tipo CELDAS. */
fun highlightCells(): Set<Int> = fun highlightCells(): Set<Int> =
if (realce == Realce.CELDAS) extraGroups().flatMap { it.asList() }.toSet() else emptySet() if (realce == Realce.CELDAS) extraGroups().flatMap { it.asList() }.toSet() else emptySet()
@@ -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(0f, 0f), Offset(size.width, size.height), 3f)
drawLine(NeonRosa.copy(alpha = 0.30f), Offset(size.width, 0f), Offset(0f, 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. // Líneas finas de todas las celdas.
for (k in 0..9) { for (k in 0..9) {
@@ -155,7 +155,13 @@ private fun FormaVariante(v: Variant, modifier: Modifier) {
val paint = remember { val paint = remember {
Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD; textAlign = Paint.Align.CENTER } 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)) { Box(modifier.clip(RoundedCornerShape(10.dp)).background(SuperficieAlta).padding(10.dp)) {
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
val cell = size.width / 9f 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() paint.textSize = cell * 0.7f; paint.color = NeonVerde.toArgb()
texto(this, paint, "", cx(3), cy(0)); texto(this, paint, "", cx(0), cy(5)) 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 -> {} else -> {}
} }
} }