Eu acho que não é segredo para ninguém que "Tolo" (daqui em diante, essa palavra será escrita com uma letra minúscula e sem aspas) é o jogo de cartas mais popular na Rússia e nos países da antiga URSS (embora seja quase desconhecido fora dela). Apesar do nome e das regras bastante simples, a conquista ainda depende mais da habilidade do jogador do que da distribuição aleatória de cartas (na terminologia inglesa, jogos de ambos os tipos são chamados jogo de habilidade e jogo de azar, respectivamente. mais jogo de habilidade ).
O objetivo do artigo é escrever uma IA simples para o jogo. A palavra "simples" significa o seguinte:
- um algoritmo intuitivo de tomada de decisão (ou seja, não há aprendizado de máquina no qual esse algoritmo esteja oculto profundamente "sob o capô");
- falta de estado (ou seja, o algoritmo é guiado apenas pelos dados no momento atual, ou seja, não se lembra de nada (por exemplo, não "conta" as cartas que deixaram o jogo).
(Estritamente falando, o primeiro parágrafo não dá mais direito a essa IA ser chamada de inteligência artificial, por si só , mas apenas uma pseudo-AI. Mas essa terminologia foi estabelecida no desenvolvimento de jogos, por isso não será alterada.)
As regras do jogo, eu acho, são conhecidas por todos, então não vou lembrá-las novamente. Quem quiser conferir, aconselho que entre em contato com a Wikipedia , há um artigo muito bom sobre esse assunto.
Então, vamos começar. Obviamente, em um tolo, quanto mais antiga a carta, melhor é tê-la na mão. Portanto, construiremos o algoritmo na avaliação clássica da força da mão e tomaremos uma decisão (por exemplo, jogar uma carta em particular) com base nessa avaliação. Atribuímos os valores aos mapas, por exemplo, assim:
- ás (A) - +600 pontos,
- rei (K) - +500,
- senhora (Q) - +400,
- Macaco (J) - +300,
- dez (10) - +200,
- nove (9) - +100,
- oito (8) - 0,
- sete (7) - -100,
- seis (6) - -200,
- cinco (5) - -300,
- quatro (4) - -400,
- três (3) - -500,
- e finalmente, empate (2) - -600 pontos.
(Usamos números que são múltiplos de 100 para eliminar o ponto flutuante em nossos cálculos e usamos apenas números inteiros. Para isso, precisamos de classificações negativas, veja abaixo no artigo.)
Os trunfos são mais valiosos do que os simples (até um trunfo de trunfo bate um ás "comum"), e a hierarquia no trunfo é a mesma, então para avaliá-los, adicionamos 1300 ao valor "base" - então, por exemplo, o trunfo "custará" -600 + 1300 = 700 pontos (ou seja, um pouco mais do que um trunfo sem fim).
No código (todos os exemplos de código do artigo estarão no Kotlin), é algo parecido com isto (a função relativaCardValue()
retorna a própria estimativa e RANK_MULTIPLIER
é apenas um 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
Infelizmente, isso não é tudo. Também é importante considerar as seguintes regras de avaliação:
- é vantajoso ter muitas cartas do mesmo valor - não apenas porque elas podem "encher" o oponente, mas também repelir facilmente o ataque (especialmente se as cartas tiverem alto valor). Por exemplo, no final do jogo, uma mão (por simplicidade, assumimos que a seguir trunfos são pandeiros)
$$ exibição $$ \ roupa de clube 2 \ roupa de mergulho 2 \ fato de diamante Q \ roupa de coração Q \ roupa de esporte Q \ roupa de esporte Q \ exibição de $$ $$ $$ quase perfeito (é claro, se o oponente não cair sob você com reis ou ases): você será derrotado pelas damas, após o que pendurar rivais dê a ele um par de duques.
mas muitas cartas do mesmo naipe (é claro, sem trunfo), pelo contrário, têm uma desvantagem - elas “interferem” uma na outra. Por exemplo, mão$$ exibição $$ \ roupa spade 5 \ roupa spade J \ roupa spade A \ roupa de diamante 6 \ roupa de diamante 9 \ roupa de diamante K $$ exibição $$ muito lamentável - mesmo se o oponente não "nocautear" seu trunfo com o primeiro movimento e for com um cartão do naipe de pico, todas as outras cartas lançadas serão de outros naipes e terão que dar trunfos. Além disso, há uma alta probabilidade de que a corrida dos cinco não seja reclamada - você tem todos os trunfos com uma dignidade maior que cinco; portanto, sob nenhuma circunstância (a menos que, é claro, você tenha entrado inicialmente com um cartão mais jovem), não será possível cobri-lo com qualquer outro cartão - é muito provável que seja necessário alto. Por outro lado, substituímos o valete de espadas por dez tacos e o trunfo seis por triplo:
$$ exibição $$ \ roupa spade 5 \ roupa de clube 10 \ roupa spade A \ roupa de diamante 3 \ roupa de diamante 9 \ roupa de diamante K $$ exibição $$ Apesar de termos substituído as cartas pelas mais jovens, essa mão é muito melhor - em primeiro lugar, você não precisará dar um trunfo no naipe de cartas (e será mais provável usar o ás de espadas) e, em segundo lugar, se você vencer qualquer depois, uma carta com o seu trunfo três, existe a chance de alguém lhe dar um três de espadas (porque geralmente não faz sentido segurar essa carta) e você "se apossará" das cinco.
Para implementar essas estratégias, modificamos nosso algoritmo: Aqui consideramos o número de cartas de cada naipe e a vantagem ...
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]++ }
... aqui adicionamos bônus para eles (a chamada Math.max
é necessária para não acumular bônus negativos para cartas baixas - porque nesse caso também é benéfico) ...
for (i in 1..13) { res += (Math.max(relativeCardValue(i), 1.0) * bonuses[countsByRank[i - 1]]).toInt() }
... e aqui, pelo contrário, multamos por um processo desequilibrado em processos (o valor UNBALANCED_HAND_PENALTY
experimentalmente definido como 200):
Por fim, levamos em conta uma coisa banal como o número de cartões em mãos. De fato, ter 12 boas cartas no início do jogo é muito bom (especialmente porque elas ainda podem ser jogadas não mais que 6), mas no final do jogo, quando há apenas um oponente com 2 cartas restantes além de você, esse não é o caso.
Resumimos - na íntegra, a função de avaliação é assim:
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)
Então, temos a função de avaliação pronta. Na próxima parte, está planejado descrever uma tarefa mais interessante - tomar decisões com base nessa avaliação.
Obrigado a todos pela atenção!
PS Este código faz parte do aplicativo desenvolvido pelo autor em seu tempo livre. Está disponível no GitHub (versões binárias para Desktop e Android, para este último o aplicativo também está disponível no F-Droid ).