Usé la programación para descubrir cómo funciona realmente el conteo de cartas

Cuando era más joven, me encantaba la película 21. Gran historia, habilidades de actuación y, obviamente, este sueño interior de ganar a lo grande y vencer al casino. Nunca aprendí a contar cartas y nunca jugué al Blackjack. Pero siempre quise comprobar si este conteo de cartas era algo real, o simplemente el señuelo de un casino salpicado en Internet gracias a grandes sumas de dinero y grandes sueños.

Hoy soy programador. Como tenía algo de tiempo extra entre la preparación del taller y el desarrollo del proyecto, decidí finalmente revelar la verdad. Así que escribí un programa mínimo que simula el juego con conteo de cartas.

¿Cómo lo hice y cuáles fueron los resultados? Veamos.

Modelo

Se supone que esta es una implementación mínima. Tan mínimo que ni siquiera he introducido el concepto de tarjeta. Las cartas están representadas por la cantidad de puntos que evalúan. Por ejemplo, un As es 11 o 1.

El mazo es una lista de números enteros y podemos generarlo como se muestra a continuación. Léalo como "cuatro 10, número del 2 al 9 y un solo 11, todo 4 veces":

fun generateDeck(): List = (List(4) { 10 } + (2..9) + 11) * 4

Definimos la siguiente función que nos permite multiplicar el contenido de List:

private operator fun  List.times(num: Int) = (1..num).flatMap { this }

La baraja del crupier no es más que 6 barajas barajadas, en la mayoría de los casinos:

fun generateDealerDeck() = (generateDeck() * 6).shuffled() 

Conteo de cartas

Las diferentes técnicas de conteo de cartas sugieren diferentes formas de contar cartas. Usaremos el más popular, que evalúa una carta como 1 cuando es menor que 7, -1 para diez y ases y 0 en caso contrario.

Esta es la implementación de Kotlin de estas reglas:

fun cardValue(card: Int) = when (card) { in 2..6 -> 1 10, 11 -> -1 else -> 0 }

Necesitamos contar todas las tarjetas usadas. En la mayoría de los casinos, podemos ver todas las cartas que se usaron.

En nuestra implementación, será más fácil para nosotros contar puntos de las cartas que quedan en el mazo y restar este número de 0. Entonces la implementación puede ser 0 — this.sumBy { card -> cardValue(card)} que es un equivalente of -this.sumBy { cardValue(it)} ue). Esta es la suma de puntos de todas las tarjetas usadas.or -sumBy(::cardVal

Lo que nos interesa es el llamado “True Count”, que es el número de puntos contados dividido por el número de mazos que quedan. Normalmente, el jugador necesita estimar este número.

En nuestra implementación, podemos usar un número mucho más preciso y calcular de trueCountesta manera:

fun List.trueCount(): Int = -sumBy(::cardValue) * 52 / size 

Estrategia de apuestas

El jugador siempre tiene que decidir antes del juego cuánto dinero apuesta. Basado en este artículo, decidí usar la regla donde el jugador calcula su unidad de apuesta, que es igual a 1/1000 de su dinero restante. Luego calculan la apuesta como una unidad de apuesta multiplicada por el conteo real menos 1. También descubrí que la apuesta debe estar entre 25 y 1000.

Aquí está la función:

fun getBetSize(trueCount: Int, bankroll: Double): Double { val bettingUnit = bankroll / 1000 return (bettingUnit * (trueCount - 1)).coerceIn(25.0, 1000.0) }

¿Qué hacer a continuación?

Hay una decisión final para nuestro jugador. En cada juego, el jugador debe realizar algunas acciones. Para tomar decisiones, el jugador debe decidir basándose en la información sobre su mano y la carta visible del crupier.

Necesitamos representar las manos del jugador y del crupier de alguna manera. Desde un punto de vista matemático, la mano no es más que una lista de cartas. Desde el punto de vista del jugador, está representado por puntos, el número de ases no utilizados si se puede dividir y si es un blackjack. Desde el punto de vista de la optimización, prefiero calcular todas estas propiedades una vez y reutilizar los valores, ya que se revisan una y otra vez.

Entonces representé la mano de esta manera:

class Hand private constructor(val cards: List) { val points = cards.sum() val unusedAces = cards.count { it == 11 } val canSplit = cards.size == 2 && cards[0] == cards[1] val blackjack get() = cards.size == 2 && points == 21 }

Ases

Hay una falla en esta función: ¿qué pasa si pasamos 21 y todavía tenemos un As sin usar? Necesitamos cambiar el As de 11 a 1 siempre que sea posible. Pero, ¿dónde debería hacerse esto? Se podría hacer en el constructor, pero sería muy engañoso si alguien configurara la mano de las cartas 11 y 11 para tener las cartas 11 y 1.

Este comportamiento debe realizarse en el método de fábrica. Después de considerarlo un poco, así es como lo implementé (también hay un operador plus implementado):

class Hand private constructor(val cards: List) { val points = cards.sum() val unusedAces = cards.count { it == 11 } val canSplit = cards.size == 2 && cards[0] == cards[1] val blackjack get() = cards.size == 2 && points == 21 operator fun plus(card: Int) = Hand.fromCards(cards + card) companion object { fun fromCards(cards: List): Hand { var hand = Hand(cards) while (hand.unusedAces >= 1 && hand.points > 21) { hand = Hand(hand.cards - 11 + 1) } return hand } } }

Las posibles decisiones se representan como una enumeración (enum):

enum class Decision { STAND, DOUBLE, HIT, SPLIT, SURRENDER } 

Es hora de implementar la función de decisión del jugador. Existen numerosas estrategias para eso.

Decidí usar este:

Lo implementé usando la siguiente función. Supuse que el casino no permite doblar:

fun decide(hand: Hand, casinoCard: Int, firstTurn: Boolean): Decision = when { firstTurn && hand.canSplit && hand.cards[0] == 11 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 9 && casinoCard !in listOf(7, 10, 11) -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 8 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] == 7 && casinoCard  SPLIT firstTurn && hand.canSplit && hand.cards[0] == 6 && casinoCard  SPLIT firstTurn && hand.canSplit && hand.cards[0] == 4 && casinoCard in 5..6 -> SPLIT firstTurn && hand.canSplit && hand.cards[0] in 2..3 && casinoCard  SPLIT hand.unusedAces >= 1 && hand.points >= 19 -> STAND hand.unusedAces >= 1 && hand.points == 18 && casinoCard  STAND hand.points > 16 -> STAND hand.points > 12 && casinoCard  STAND hand.points > 11 && casinoCard in 4..6 -> STAND hand.unusedAces >= 1 && casinoCard in 2..6 && hand.points >= 18 -> if (firstTurn) DOUBLE else STAND hand.unusedAces >= 1 && casinoCard == 3 && hand.points >= 17 -> if (firstTurn) DOUBLE else HIT hand.unusedAces >= 1 && casinoCard == 4 && hand.points >= 15 -> if (firstTurn) DOUBLE else HIT hand.unusedAces >= 1 && casinoCard in 5..6 -> if (firstTurn) DOUBLE else HIT hand.points == 11 -> if (firstTurn) DOUBLE else HIT hand.points == 10 && casinoCard  if (firstTurn) DOUBLE else HIT hand.points == 9 && casinoCard in 3..6 -> if (firstTurn) DOUBLE else HIT else -> HIT }

¡Vamos a jugar!

Todo lo que necesitamos ahora es una simulación de juego. ¿Qué pasa en un juego? Primero, las cartas se toman y se barajan.

Representémoslos como una lista mutable:

val cards = generateDealerDeck().toMutableList() 

Necesitaremos popfunciones para ello:

fun  MutableList.pop(): T = removeAt(lastIndex) fun  MutableList.pop(num: Int): List = (1..num).map { pop() }

También necesitamos saber cuánto dinero tenemos:

var bankroll = initialMoney

Luego jugamos iterativamente hasta… ¿hasta cuándo? Según este foro, normalmente es hasta que se utiliza el 75% de las tarjetas. Luego se barajan las cartas, por lo que básicamente comenzamos desde el principio.

So we can implement it like that:

val shufflePoint = cards.size * 0.25 while (cards.size > shufflePoint) {

The game starts. The casino takes single card:

val casinoCard = cards.pop()

Other players take cards as well. These are burned cards, but we will burn them later to let the player now include them during the points calculation (burning them now would give player information that is not really accessible at this point).

We also take a card and we make decisions. The problem is that we start as a single player, but we can split cards and attend as 2 players.

Therefore, it is better to represent gameplay as a recursive process:

fun playFrom(playerHand: Hand, bet: Double, firstTurn: Boolean): List
    
      = when (decide(playerHand, casinoCard, firstTurn)) { STAND -> listOf(bet to playerHand) DOUBLE -> playFrom(playerHand + cards.pop(), bet * 2, false) HIT -> playFrom(playerHand + cards.pop(), bet, false) SPLIT -> playerHand.cards.flatMap { val newCards = listOf(it, cards.pop()) val newHand = Hand.fromCards(newCards) playFrom(newHand, bet, false) } SURRENDER -> emptyList() }
    

If we don’t split, the returned value is always a single bet and a final hand.

If we split, the list of two bets and hands will be returned. If we fold, then an empty list is returned.

This is how we should start this function:

val betsAndHands = playFrom( playerHand = Hand.fromCards(cards.pop(2)), bet = getBetSize(cards.trueCount(), bankroll), firstTurn = true )

After that, the casino dealer needs to play their game. It is much simpler, because they only get a new card when they have less then 17 points. Otherwise he holds.

var casinoHand = Hand.fromCards(listOf(casinoCard, cards.pop())) while (casinoHand.points < 17) { casinoHand += cards.pop() }

Then we need to compare our results.

We need to do it for every hand separately:

for ((bet, playerHand) in betsAndHands) { when { playerHand.blackjack -> bankroll += bet * if (casinoHand.blackjack) 1.0 else 1.5 playerHand.points > 21 -> bankroll -= bet casinoHand.points > 21 -> bankroll += bet casinoHand.points > playerHand.points -> bankroll -= bet casinoHand.points  bankroll += bet else -> bankroll -= bet } }

We can finally burn some cards used by other players. Let’s say that we play with two other people and they use 3 cards on average each:

cards.pop(6)

That’s it! This way the simulation will play the whole dealer’s deck and then it will stop.

At this moment, we can check out if we have more or less money then before:

val differenceInBankroll = bankroll - initialMoney return differenceInBankroll

The simulation is very fast. You can make thousands of simulations in seconds. This way you can easily calculate the average result:

(1..10000).map { simulate() }.average().let(::print)

Start with this algorithm and have fun. Here you can play with the code online:

Blackjack

Kotlin right in the browser.try.kotlinlang.org

Results

Sadly my simulated player still loses money. Much less than a standard player, but this counting didn’t help enough. Maybe I missed something. This is not my discipline.

Correct me if I am wrong ;) For now, this whole card-counting looks like a huge scam. Maybe this website just presents a bad algorithm. Although this is the most popular algorithm I found!

These results might explain why even though there have been known card-counting techniques for years — and all these movies were produced (like 21) — casinos around the world still offer Blackjack so happily.

I believe that they know (maybe it is even mathematically proven) that the only way to win with a casino is to not play at all. Like in nearly every other hazard game.

About the author

Marcin Moskała (@marcinmoskala) is a trainer and consultant, currently concentrating on giving Kotlin in Android and advanced Kotlin workshops (contact form to apply for your team). He is also a speaker, author of articles and a book about Android development in Kotlin.