Creo que no es ningún secreto para nadie que "Tonto" (en adelante, esta palabra se escribirá con una letra minúscula y sin comillas) es el juego de cartas más popular en Rusia y en los países de la antigua URSS (aunque es casi desconocido fuera de él). A pesar de su nombre y sus reglas bastante simples, ganarlo aún depende más de la habilidad del jugador que de la distribución aleatoria de las cartas (en terminología inglesa, los juegos de ambos tipos se llaman juego de habilidad y juego de azar, respectivamente. Entonces, un tonto en Más juego de habilidad ).
El propósito del artículo es escribir una IA simple para el juego. La palabra "simple" significa lo siguiente:
- un algoritmo intuitivo para la toma de decisiones (es decir, como resultado, no hay aprendizaje automático en el que este algoritmo se oculta profundamente "bajo el capó");
- falta de estado (es decir, el algoritmo se guía solo por los datos en el momento actual, simplemente, no recuerda nada (por ejemplo, no "cuenta" las cartas que han abandonado el juego).
(Hablando estrictamente, el primer párrafo ya no da derecho a que una IA se llame inteligencia artificial per se , sino solo una pseudo-IA. Pero esta terminología se ha establecido en el desarrollo del juego, por lo que no la cambiaremos).
Creo que las reglas del juego son conocidas por todos, así que no las recordaré nuevamente. Aquellos que quieran consultarlo, les aconsejo que se contacten con Wikipedia , hay un artículo bastante bueno sobre este tema.
Entonces comencemos. Obviamente, en el caso de un tonto, cuanto más antigua sea la tarjeta, mejor será tenerla en la mano. Por lo tanto, construiremos el algoritmo sobre la evaluación clásica de la fuerza de la mano y tomaremos una decisión (por ejemplo, lanzar una carta en particular) basada en esta evaluación. Asignamos los valores a los mapas, por ejemplo, así:
- as (A) - +600 puntos,
- rey (K) - +500,
- dama (Q) - +400,
- Jack (J) - +300,
- diez (10) - +200,
- nueve (9) - +100,
- ocho (8) - 0,
- siete (7) - -100,
- seis (6) - -200,
- cinco (5) - -300,
- cuatro (4) - -400,
- tres (3) - -500,
- y finalmente, deuce (2) - -600 puntos.
(Utilizamos números que son múltiplos de 100 para deshacernos del punto flotante en nuestros cálculos y solo usamos enteros. Para esto necesitamos calificaciones negativas, ver más abajo en el artículo).
Las cartas de Trump son más valiosas que cualquier carta simple (incluso un triunfo de deuce gana un as "ordinario"), y la jerarquía en el palo de triunfo es la misma, por lo que para evaluarlas simplemente agregamos 1300 al valor "base" - luego, por ejemplo, el triunfo de deuce "costará" -600 + 1300 = 700 puntos (es decir, solo un poco más que una carta de triunfo sin ace).
En el código (todos los ejemplos de código en el artículo estarán en Kotlin), se ve más o menos así (la función relativaCardValue()
devuelve la estimación, y RANK_MULTIPLIER
es solo un coeficiente igual a 100):
for (c in hand) { val r = c.rank val s = c.suit res += ((relativeCardValue(r.value)) * RANK_MULTIPLIER).toInt() if (s === trumpSuit) res += 13 * RANK_MULTIPLIER
Por desgracia, eso no es todo. También es importante tener en cuenta las siguientes reglas de evaluación:
- es ventajoso tener muchas cartas del mismo valor, no solo porque pueden "llenar" al oponente, sino que también repelen fácilmente el ataque (especialmente si las cartas son de alto valor). Por ejemplo, al final del juego, una mano (por simplicidad, suponemos que en adelante los triunfos son panderetas)
$$ display $$ \ clubsuit 2 \ spadesuit 2 \ diamondsuit Q \ heartsuit Q \ clubsuit Q \ spadesuit Q $$ display $$ casi perfecto (por supuesto, si el oponente no te ataca con reyes o ases): las damas te derrotarán, después de lo cual colgar rivales dale un par de deuces.
pero muchas cartas del mismo palo (por supuesto, sin triunfo), por el contrario, tienen una desventaja: “interferirán” entre sí. Por ejemplo, mano$$ display $$ \ spadesuit 5 \ spadesuit J \ spadesuit A \ diamondsuit 6 \ diamondsuit 9 \ diamondsuit K $$ display $$ muy desafortunado: incluso si el oponente no "noquea" su carta de triunfo con el primer movimiento y va con una carta del palo máximo, todas las demás cartas lanzadas serán de otros palos, y tendrán que dar cartas de triunfo. Además, existe una alta probabilidad de que las cinco carreras continúen sin reclamar: tiene todas las cartas de triunfo con una dignidad superior a cinco, por lo que bajo ninguna circunstancia (a menos, por supuesto, que ingresó inicialmente con una tarjeta más joven) no podrá cubrirla con ninguna otra tarjeta; es muy probable que tome alto Por otro lado, reemplazamos la jota de picas con diez palos y el triunfo seis con el triple:
$$ display $$ \ spadesuit 5 \ clubsuit 10 \ spadesuit A \ diamondsuit 3 \ diamondsuit 9 \ diamondsuit K $$ display $$ A pesar del hecho de que reemplazamos las cartas por otras más jóvenes, tal mano es mucho mejor: en primer lugar, no tendrá que dar una carta de triunfo en el palo de cartas (y será más probable que use el as de espadas), y en segundo lugar, si vence a cualquiera luego, una carta con tu triunfo tres, existe la posibilidad de que alguien te arroje un tres de espadas (porque generalmente no tiene sentido sostener una carta así), y tú "agarrarás" las cinco.
Para implementar estas estrategias, modificamos nuestro algoritmo: aquí consideramos el número de cartas de cada palo y la ventaja ...
val bonuses = doubleArrayOf(0.0, 0.0, 0.5, 0.75, 1.25) var res = 0 val countsByRank = IntArray(13) val countsBySuit = IntArray(4) for (c in hand) { val r = c.rank val s = c.suit res += ((relativeCardValue(r.value)) * RANK_MULTIPLIER).toInt() if (s === trumpSuit) res += 13 * RANK_MULTIPLIER countsByRank[r.value - 1]++ countsBySuit[s.value]++ }
... aquí agregamos bonos para ellos (la llamada Math.max
es necesaria para no acumular bonos negativos para cartas bajas, porque en este caso también es beneficioso) ...
for (i in 1..13) { res += (Math.max(relativeCardValue(i), 1.0) * bonuses[countsByRank[i - 1]]).toInt() }
... y aquí, por el contrario, estamos bien por un traje desequilibrado en trajes (el valor UNBALANCED_HAND_PENALTY
establece experimentalmente en 200):
Finalmente, tenemos en cuenta algo tan banal como el número de cartas en la mano. De hecho, tener 12 buenas cartas al comienzo del juego es muy bueno (especialmente porque todavía no se pueden lanzar más de 6), pero al final del juego, cuando solo hay un oponente con 2 cartas además de ti, este no es el caso en absoluto.
Resumimos: en su totalidad, la función de evaluación se ve así:
private fun handValue(hand: ArrayList<Card>, trumpSuit: Suit, cardsRemaining: Int, playerHands: Array<Int>): Int { if (cardsRemaining == 0 && hand.size == 0) { return OUT_OF_PLAY } val bonuses = doubleArrayOf(0.0, 0.0, 0.5, 0.75, 1.25)
Entonces, tenemos lista la función de evaluación. En la siguiente parte, se planea describir una tarea más interesante: tomar decisiones basadas en dicha evaluación.
¡Gracias a todos por su atención!
PD Este código es parte de la aplicación desarrollada por el autor en su tiempo libre. Está disponible en GitHub (versiones binarias para escritorio y Android, para este último la aplicación también está disponible en F-Droid ).