Programozással rájöttem, hogyan is működik a kártyaszámolás

Fiatalabb koromban imádtam a 21. filmet. Remek történet, színészi képességek és nyilvánvalóan ez a belső álom, hogy hatalmasat nyerjünk és legyőzzük a kaszinót. Soha nem tanultam meg kártyákat számlálni, és még soha nem is játszottam Blackjack-ot. De mindig meg akartam nézni, hogy ez a kártyaszámlálás valódi dolog-e, vagy csak egy kaszinó csalija fröccsen az interneten a nagy pénznek és a nagy álmoknak köszönhetően.

Ma programozó vagyok. Mivel volt némi extra időm a műhely-előkészületek és a projektfejlesztés között, úgy döntöttem, hogy végre kiderítem az igazságot. Így írtam egy minimális programot, amely szimulálja a játékmenetet és a kártyaszámlálást.

Hogyan csináltam, és milyen eredmények születtek? Lássuk.

Modell

Állítólag ez egy minimális megvalósítás. Olyan minimális, hogy még a kártya fogalmát sem vezettem be. A kártyákat az általuk értékelt pontok száma képviseli. Például egy ász 11 vagy 1.

A fedélzet egész számok listája, és az alábbiak szerint generálhatjuk. Olvassa el a következőket: „négy 10, szám 2-től 9-ig és egyetlen 11, mindent négyszer”:

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

Meghatározzuk a következő függvényt, amelynek szorozzuk meg a tartalmát List:

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

A kereskedő paklija nem más, mint 6 pakli keverése - a legtöbb kaszinóban:

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

Kártya számlálás

A különböző kártyaszámlálási technikák a kártyák számlálásának különböző módjait javasolják. A legnépszerűbbet fogjuk használni, amely egy kártyát 1-nek értékel, ha kisebb, mint 7, -1-et tízeknek és ászoknak, 0-t egyébként.

Ez a Kotlin végrehajtja ezeket a szabályokat:

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

Meg kell számolnunk az összes használt kártyát. A legtöbb kaszinóban láthatjuk az összes használt kártyát.

Megvalósításunk során könnyebben számolhatunk pontokat a pakliban maradt kártyákról, és kivonhatjuk ezt a számot 0-ból. Tehát a megvalósítás lehet 0 — this.sumBy { card -> cardValue(card)} ami egyenértékű of -this.sumBy { cardValue(it)} ue). Ez az összes használt kártya pontösszege.or -sumBy(::cardVal

Ami érdekel, az úgynevezett „True Count”, amely a megszámlált pontok számának elosztva a megmaradt fedélzetek számával. Normális esetben a játékosnak meg kell becsülnie ezt a számot.

Megvalósításunk során sokkal pontosabb számot használhatunk, és így számolhatunk trueCount:

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

Fogadási stratégia

A játékosnak mindig a meccs előtt el kell döntenie, hogy mennyi pénzt fogad. E cikk alapján úgy döntöttem, hogy alkalmazom azt a szabályt, ahol a játékos kiszámítja a fogadási egységét - ami megegyezik a megmaradt pénzük 1/1000-jával. Aztán kiszámítják a fogadást, mint a valódi szám mínusz 1 szorzatát. Megtudtam azt is, hogy a tétnek 25 és 1000 között kell lennie.

Itt van a függvény:

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

Mi legyen a következő?

Egyetlen döntést hozhatunk a játékosunkra. Minden játékban a játékosnak el kell végeznie néhány műveletet. A döntések meghozatalához a játékosnak a kezével kapcsolatos információk és az osztó látható kártyája alapján kell dönteni.

Valahogyan képviselnünk kell a játékos és az osztó kezét. Matematikai szempontból a kéz nem más, mint a kártyák listája. A játékos szempontjából pontok, a fel nem használt ászok száma képviseli, ha fel lehet osztani, és ha blackjackról van szó. Optimalizálási szempontból legszívesebben kiszámítom ezeket a tulajdonságokat egyszer, és újra felhasználom az értékeket, mivel újra és újra ellenőrzik őket.

Tehát így ábrázoltam a kezet:

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 }

Ászok

Ebben a funkcióban egy hiba van: mi van, ha átmegyünk a 21-nél, és még mindig használaton kívüli ászunk van? Meg kell változtatnunk az ászt 11-ről 1-re, amíg ez lehetséges. De hol kell ezt megtenni? Meg lehet csinálni a konstruktorban, de nagyon félrevezető lenne, ha valaki a 11-es és 11-es lapokból odaadná a kezét, hogy legyen 11 és 1 kártya.

Ezt a viselkedést a gyári módszerrel kell elvégezni. Némi megfontolás után így valósítottam meg (van plusz operátor is):

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 } } }

A lehetséges döntéseket felsorolásként (enum) képviselik:

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

Ideje a játékos döntési funkciójának megvalósításához. Számos stratégia létezik erre.

Úgy döntöttem, hogy ezt használom:

A következő függvény segítségével valósítottam meg. Feltételeztem, hogy a kaszinó nem engedi meg a hajtogatást:

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 }

Játsszunk!

Most csak egy játék szimulációra van szükségünk. Mi történik egy játékban? Először a kártyákat veszik és keverik meg.

Képviseljük őket egy módosítható listaként:

val cards = generateDealerDeck().toMutableList() 

Szükségünk lesz rá popfunkciókra:

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

Azt is tudnunk kell, hogy mennyi pénzünk van:

var bankroll = initialMoney

Aztán iteratívan játszunk, amíg ... addig nem? E fórum szerint általában addig van, amíg a kártyák 75% -át ki nem használják. Ezután a kártyákat összekeverik, tehát alapvetően az elejétől kezdjük.

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.