Я використовував програмування, щоб зрозуміти, як насправді працює підрахунок карт

Коли я був молодшим, я любив фільм 21. Чудова історія, акторська майстерність, і, очевидно, ця внутрішня мрія перемогти величезне і перемогти казино. Я ніколи не вчився рахувати карти, і насправді ніколи не грав у Блекджек. Але я завжди хотів перевірити, чи справді цей підрахунок карток, чи просто приманка казино виплеснулася в Інтернеті завдяки великим грошам і великим мріям.

Сьогодні я програміст. Оскільки у мене був додатковий час між підготовкою семінару та розробкою проекту, я вирішив нарешті розкрити правду. Тож я написав мінімальну програму, яка імітує геймплей з підрахунком карт.

Як я це зробив, і які були результати? Подивимось.

Модель

Це має бути мінімальним впровадженням. Настільки мінімальний, що я навіть не представив поняття картки. Карти представлені кількістю балів, за які вони оцінюються. Наприклад, туз - 11 або 1.

Колода - це список цілих чисел, і ми можемо створити його, як показано нижче. Прочитайте це як "чотири 10, число від 2 до 9 і одиночне 11, все 4 рази":

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

Ми визначаємо наступну функцію, яка дозволить нам помножити вміст List:

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

Колода дилера - це не що інше, як перемішування 6 колод - у більшості казино:

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

Підрахунок карт

Різні техніки підрахунку карток пропонують різні способи підрахунку карт. Ми будемо використовувати найпопулярніший, який оцінює карту як 1, коли вона менша за 7, -1 для десятків і тузів, а в протилежному випадку - 0.

Це реалізація Котліном цих правил:

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

Нам потрібно порахувати всі використані картки. У більшості казино ми бачимо всі карти, які використовувались.

У нашому впровадженні нам буде простіше підрахувати очки з карток, що залишилися в колоді, і відняти це число від 0. Отже, реалізація може бути 0 — this.sumBy { card -> cardValue(card)} що є еквівалентом of -this.sumBy { cardValue(it)} ue). Це сума балів за всі використані картки.or -sumBy(::cardVal

Нас цікавить так званий «Справжній рахунок», тобто кількість підрахованих очок, поділена на кількість колод, які залишились. Зазвичай гравцеві потрібно оцінити це число.

У нашій реалізації ми можемо використовувати набагато точніше число і обчислити trueCountтаким чином:

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

Стратегія ставок

Гравець завжди повинен вирішити перед грою, скільки грошей він ставить. На основі цієї статті я вирішив використати правило, згідно з яким гравець обчислює свою одиницю ставок - яка дорівнює 1/1000 їхніх грошей, що залишились. Потім вони обчислюють ставку як одиницю ставки, помножену на справжню кількість мінус 1. Я також виявив, що ставка повинна бути між 25 і 1000.

Ось функція:

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

Що робити далі?

Для нашого гравця є остаточне рішення. У кожній грі гравцеві потрібно виконати певні дії. Для прийняття рішень гравцеві потрібно прийняти рішення, виходячи з інформації про його руку та видимої карти дилера.

Нам потрібно якось представляти руки гравців та дилерів. З математичної точки зору, рука є не що інше, як перелік карт. З точки зору гравця, він представлений балами, кількістю невикористаних тузів, якщо його можна розділити, і якщо це блекджек. З точки зору оптимізації, я віддаю перевагу обчислювати всі ці властивості один раз і використовувати значення повторно, оскільки вони перевіряються знову і знову.

Тож я представив руку так:

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 }

Тузи

У цій функції є одна вада: що робити, якщо ми пройдемо 21 рік і у нас залишиться невикористаний Туз? Нам потрібно змінити Туза з 11 на 1, доки це можливо. Але де це слід робити? Це можна було зробити в конструкторі, але було б дуже оманливим, якщо б хтось встановив руку з карт 11 і 11 на карти 11 і 1.

Така поведінка повинна виконуватися заводським методом. Після деяких роздумів, ось як я це реалізував (є також реалізований плюс оператор):

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

Можливі рішення представлені як перерахування (перерахування):

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

Час реалізувати функцію прийняття рішення гравцем. Для цього існує безліч стратегій.

Я вирішив використати цей:

Я реалізував його, використовуючи наступну функцію. Я припустив, що казино не допускає складання:

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 }

Давай грати!

Зараз нам потрібно лише симуляція гри. Що відбувається в грі? Спочатку беруть і тасують картки.

Давайте представимо їх як змінний список:

val cards = generateDealerDeck().toMutableList() 

Для цього нам знадобляться popфункції:

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

Ми також повинні знати, скільки у нас грошей:

var bankroll = initialMoney

Тоді ми граємо ітеративно до ... доки? Відповідно до цього форуму, як правило, поки не використовується 75% карток. Потім картки перемішуються, тому ми в основному починаємо спочатку.

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.