Jeg brugte programmering til at finde ud af, hvordan korttælling virkelig fungerer

Da jeg var yngre, elskede jeg filmen 21. Fantastisk historie, skuespilfærdigheder og naturligvis denne indre drøm om at vinde stort og slå casinoet. Jeg har aldrig lært at tælle kort, og jeg har faktisk aldrig spillet Blackjack. Men jeg har altid ønsket at kontrollere, om dette kortoptælling var en rigtig ting, eller bare et casinos lokke sprøjtede på internettet takket være store penge og store drømme.

I dag er jeg programmør. Da jeg havde lidt ekstra tid mellem workshopforberedelser og projektudvikling, besluttede jeg mig for endelig at afsløre sandheden. Så jeg skrev et minimalt program, der simulerer gameplay med kortoptælling.

Hvordan gjorde jeg det, og hvad var resultaterne? Lad os se.

Model

Dette formodes at være en minimal implementering. Så minimalt, at jeg ikke engang har introduceret konceptet med et kort. Kort er repræsenteret af antallet af point, de vurderer til. For eksempel er et es 11 eller 1.

Dækket er en liste over heltal, og vi kan generere det som vist nedenfor. Læs det som “fire 10, nummer fra 2 til 9 og enkelt 11, alt 4 gange”:

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

Vi definerer følgende funktion, der lader os multiplicere indholdet af List:

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

Dealerens bunke er intet andet end 6 dæk blandet - i de fleste kasinoer:

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

Korttælling

Forskellige korttællingsteknikker antyder forskellige måder at tælle kort på. Vi bruger den mest populære, som evaluerer et kort som 1, når det er mindre end 7, -1 for tiere og ess, og ellers 0.

Dette er Kotlin-implementeringen af ​​disse regler:

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

Vi skal tælle alle brugte kort. I de fleste kasinoer kan vi se alle de kort, der blev brugt.

I vores implementering vil det være lettere for os at tælle point fra kort, der er tilbage i bunken og trække dette tal fra 0. Så implementeringen kan være 0 — this.sumBy { card -> cardValue(card)} hvilket er en ækvivalent of -this.sumBy { cardValue(it)} ue). Dette er summen af ​​point for alle brugte kort.or -sumBy(::cardVal

Det, vi er interesseret i, er den såkaldte “True Count”, som er antallet af optalte punkter divideret med antallet af dæk, der er tilbage. Normalt skal spilleren estimere dette antal.

I vores implementering kan vi bruge et meget mere nøjagtigt tal og beregne på trueCountdenne måde:

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

Væddemålsstrategi

Spilleren skal altid beslutte, før spillet, hvor mange penge de satser. Baseret på denne artikel besluttede jeg at bruge reglen, hvor spilleren beregner deres væddemålsenhed - hvilket svarer til 1/1000 af deres penge tilbage. Derefter beregner de indsatsen, da en væddemålsenhed gange det sande antal minus 1. Jeg fandt også ud af, at væddemålet skulle være mellem 25 og 1000.

Her er funktionen:

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

Hvad skal jeg gøre nu?

Der er en endelig beslutning for vores spiller. I hvert spil skal spilleren foretage nogle handlinger. For at træffe beslutninger skal spilleren beslutte ud fra oplysningerne om deres hånd og dealerens synlige kort.

Vi er nødt til at repræsentere spiller- og dealerhænder på en eller anden måde. Fra et matematisk synspunkt er hånden intet andet end en liste over kort. Fra spillerens synspunkt er det repræsenteret af point, antallet af ubrugte ess, hvis det kan deles, og hvis det er en blackjack. Fra optimeringsmæssigt synspunkt foretrækker jeg at beregne alle disse egenskaber en gang og genbruge værdierne, da de kontrolleres igen og igen.

Så jeg repræsenterede hånden på denne måde:

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 }

Esser

Der er en fejl i denne funktion: Hvad hvis vi passerer 21, og vi stadig har et ubrugt ess? Vi er nødt til at ændre esset fra 11 til 1, så længe dette er muligt. Men hvor skal dette gøres? Det kunne gøres i konstruktøren, men det ville være meget vildledende, hvis nogen satte hånden fra kort 11 og 11 til at have kort 11 og 1.

Denne adfærd skal udføres fra fabriksmetoden. Efter en vis overvejelse implementerede jeg det (der er også implementeret plus operatør):

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

Mulige beslutninger er repræsenteret som en optælling (enum):

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

Tid til at implementere spillerens beslutningsfunktion. Der er adskillige strategier for det.

Jeg besluttede at bruge denne:

Jeg implementerede det ved hjælp af følgende funktion. Jeg antog, at foldning ikke er tilladt af kasinoet:

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 }

Lad os lege!

Alt, hvad vi har brug for nu, er en spilsimulering. Hvad sker der i et spil? Først tages kortene og blandes.

Lad os repræsentere dem som en ændret liste:

val cards = generateDealerDeck().toMutableList() 

Vi har brug for popfunktioner til det:

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

Vi har også brug for at vide, hvor mange penge vi har:

var bankroll = initialMoney

Så spiller vi iterativt indtil ... indtil hvornår? Ifølge dette forum er det normalt indtil 75% af kortene bruges. Derefter blandes kortene, så vi starter grundlæggende fra starten.

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) er en træner og konsulent, der i øjeblikket koncentrerer sig om at give Kotlin i Android og avancerede Kotlin-workshops (kontaktformular for at ansøge om dit team). Han er også taler, forfatter til artikler og en bog om Android-udvikling i Kotlin.