DEV Community

loading...
Cover image for Real world Javascript map/reduce, solving the Poker Hand problem

Real world Javascript map/reduce, solving the Poker Hand problem

miketalbot profile image Mike Talbot ・6 min read

You, like me, may have a play at solving some of the daily challenges here on DEV. Usually if the answer is more than one or two lines of some fancy function I don't have the time, but yesterday the challenge was to rank poker hands and it struck me as one of those things that "should" be easy!

The end result works well, is concise and readable (it's a lot shorter than the other proposed solutions at least).

Sure enough we can press into service map and reduce to get us the information we need. But it's a really nice example of how to use these tools to solve a multi-step problem in the real world.

The challenge

The challenge is to rank two poker hands and decide which one wins.

Poker hands are represented by strings of 2 characters separated by a space. So 2H is the 2 of Hearts and TC is the ten of clubs etc.

"2C 5C 3C 4C 6C" is a straight flush in clubs to the 6.

The hand rankings are as for Texas Hold'em.

Alt Text

There are hidden complexities though in ranking hands - using supplementary cards to resolve a draw and using the face value of pairs etc.

The solution

Ok so how to solve this problem. Firstly we need a way of comparing hands that solves for hand ranking and then when the rankings match, resolving a winner if possible by comparing supplementary cards.

As the challenge specifies that no suit is better than another we propose a simple object to represent hand rankings:

{ 
   rank: 1,       // A value from 1 - 9 to rank hands, lower is better
   value: 'ABCDE' // A value that represents the faces to compare, lower is better
}

We can now write a simple function to compare two hands that are represented by this structure:

function compareHands(h1, h2) {
    let d1 = getHandDetails(h1)
    let d2 = getHandDetails(h2)
    if (d1.rank === d2.rank) {
        if (d1.value < d2.value) {
            return "WIN"
        } else if (d1.value > d2.value) {
            return "LOSE"
        } else {
            return "DRAW"
        }
    }
    return d1.rank < d2.rank ? "WIN" : "LOSE"
}

So now all we have to do is to create the result object from the hand - this is where the fun starts!

Getting the details of a poker hand

So in solving problems like this you need to work out the core data that you need to resolve the problem. Here our first problem is to rank hands.

Poker hands can be a straight, a flush or some combination of multiple cards with the same face value. Our job is first to assemble this information from our input string. The first step of that is to decide how we want to parse our input.

Parsing the input

    const input = "AH KS TC 9D 3S" // Something like this

We need both suits and faces, but given that the only reason we care about suits is if they are all the same, then there is no need to keep the face and the suit related. This makes parsing pretty straight forward.

  1. Convert the string into cards
  2. Extract the face and the suit

    However, if we want to be able to sort our face cards we need them to be easily compared to each other. For instance A > K (Ace is better than King) but Q > J (Queen is better than Jack) so it's not alphabetical. So we add a third step:

  3. Convert the face into something easily comparable

We have 5 cards in the hand, at the end of this we want a value to resolve draws that can be compared in a single operation - so it needs to be a string. Therefore we will rank our card faces as characters so we can put them back into a string later. Just now we want A to be Ace, B to be King, C to be Queen etc

const order = "23456789TJQKA"

    const cards = hand.split(" ") // Split into cards
    const faces = cards.map(a => String.fromCharCode([77 - order.indexOf(a[0])])).sort() 
    const suits = cards.map(a => a[1]).sort()

So here we've extracted the cards and the faces, mapped the faces to A onwards by looking up their position in the order string and taking this value away from 77, turning that back into a string. 65 is the code for A so this creates us a comparable string starting with A being best.

We also sorted the faces and the suits, that's so we can do the next step!

Creating comparable data

Ok we now need to generate some more data so we can write some code to rank the hand.

  1. Identify a flush
  2. Identify a straight
  3. Identify duplicate faces - which we will use for all of the other types of hand

Identify a flush

This is super easy now we've parsed the data and sorted the suits. If the last suit entry is the same as the first, we have a flush.

const flush = suits[0] === suits[4]

Identify a straight

A straight isn't much harder, if the cards are all in sequence we know it is a straight.

So we find the first card and use every to check that the values are sequential, using the index passed to the callback like so:

    const first = faces[0].charCodeAt(0)
    const straight = faces.every((f, index) => f.charCodeAt(0) - first === index)

Identify duplicates

Ok so this step is a little harder, we need to count the number of each face in our hand, but then we need some way of identifying pairs, 3 of a kind etc to make it easy to rank the hand so what we want to do here is:

  • Count the number of each face
  • Convert the count into something we can lookup

    We want to be able to say "is there a four of a kind", how many pairs are there etc

So first we count the faces:

    const counts = faces.reduce(count, {})

function count(c, a) {
    c[a] = (c[a] || 0) + 1
    return c
}

And then we make a lookup out of those counts by simply "counting the counts"!:

    const duplicates = Object.values(counts).reduce(count, {})

Rank the hand

We now have all the information we need to rank the hand, without the draw resolution.

    let rank =
        (flush && straight && 1) ||
        (duplicates[4] && 2) ||
        (duplicates[3] && duplicates[2] && 3) ||
        (flush && 4) ||
        (straight && 5) ||
        (duplicates[3] && 6) ||
        (duplicates[2] > 1 && 7) ||
        (duplicates[2] && 8) ||
        9

So a straight flush wins with rank 1 (we will let the draw resolution fix a royal straight flush), then four of a kind, full house etc

This uses fancy Javascript && to which resolve to the last value if the previous are truthy. So (flush && straight && 1) returns 1 if flush and straight are true, otherwise false.

Value resolution

If two hands resolve the same rank we need to disambiguate them if possible. This does have some rules associated.

  • Pair versus pair, highest pair wins. If they are the same, highest next card wins. (Works for 2 pairs as well)

    So we compare 2H 2D AH KC 3D with 4H 4C JC TC 3H and the 4's win even though the first hand has a higher next card - an ace.

  • Full house versus full house, it's the highest triple that wins.

So we need to sort by count and then by face value in our output string. Remember be want a five character string in order that can be used to resolve a rank match.

    let value = faces.sort(byCountFirst).join("")

function byCountFirst(a, b) {
    //Counts are in reverse order - bigger is better
    const countDiff = counts[b] - counts[a]

    if (countDiff) return countDiff // If counts don't match return
    return b > a ? -1 : b === a ? 0 : 1
}

And that's it!

The Whole Shebang

const order = "23456789TJQKA"
function getHandDetails(hand) {
    const cards = hand.split(" ")
    const faces = cards.map(a => String.fromCharCode([77 - order.indexOf(a[0])])).sort()
    const suits = cards.map(a => a[1]).sort()
    const counts = faces.reduce(count, {})
    const duplicates = Object.values(counts).reduce(count, {})
    const flush = suits[0] === suits[4]
    const first = faces[0].charCodeAt(0)
    const straight = faces.every((f, index) => f.charCodeAt(0) - first === index)
    let rank =
        (flush && straight && 1) ||
        (duplicates[4] && 2) ||
        (duplicates[3] && duplicates[2] && 3) ||
        (flush && 4) ||
        (straight && 5) ||
        (duplicates[3] && 6) ||
        (duplicates[2] > 1 && 7) ||
        (duplicates[2] && 8) ||
        9

    return { rank, value: faces.sort(byCountFirst).join("") }

    function byCountFirst(a, b) {
        //Counts are in reverse order - bigger is better
        const countDiff = counts[b] - counts[a]
        if (countDiff) return countDiff // If counts don't match return
        return b > a ? -1 : b === a ? 0 : 1
    }

    function count(c, a) {
        c[a] = (c[a] || 0) + 1
        return c
    }
}

function compareHands(h1, h2) {
    let d1 = getHandDetails(h1)
    let d2 = getHandDetails(h2)
    if (d1.rank === d2.rank) {
        if (d1.value < d2.value) {
            return "WIN"
        } else if (d1.value > d2.value) {
            return "LOSE"
        } else {
            return "DRAW"
        }
    }
    return d1.rank < d2.rank ? "WIN" : "LOSE"
}

Conclusion

As you can see, if we break down the problem we can easily apply map and reduce to prepare all of the information we need to solve this problem.

If you have heavy lifting to do in Javascript and don't want to glitch, check out my js-coroutines library which could well help you out.

Discussion (10)

pic
Editor guide
Collapse
miketalbot profile image
Mike Talbot Author • Edited

The original challenge specified no low straights from the Ace, but it jarred with me and this routine is easily adjustable to support it. We just have to worry about sort orders during straights and to recognise the straight in the first place. The following updated version of getHandDetails() manages both of these:

const order = "23456789TJQKA"
function getHandDetails(hand) {
    const cards = hand.split(" ")
    const faces = cards.map(a => String.fromCharCode([77 - order.indexOf(a[0])])).sort()
    const suits = cards.map(a => a[1]).sort()
    const counts = faces.reduce(count, {})
    const duplicates = Object.values(counts).reduce(count, {})
    const flush = suits[0] === suits[4]
    const first = faces[0].charCodeAt(0)
    //Also handle low straight
    const lowStraight = faces.join("") === "AJKLM"
    faces[0] = lowStraight ? "N" : faces[0]
    const straight = lowStraight || faces.every((f, index) => f.charCodeAt(0) - first === index)
    let rank =
        (flush && straight && 1) ||
        (duplicates[4] && 2) ||
        (duplicates[3] && duplicates[2] && 3) ||
        (flush && 4) ||
        (straight && 5) ||
        (duplicates[3] && 6) ||
        (duplicates[2] > 1 && 7) ||
        (duplicates[2] && 8) ||
        9

    return { rank, value: faces.sort(byCountFirst).join("") }

    function byCountFirst(a, b) {
        //Counts are in reverse order - bigger is better
        const countDiff = counts[b] - counts[a]
        if (countDiff) return countDiff // If counts don't match return
        return b > a ? -1 : b === a ? 0 : 1
    }
    function count(c, a) {
        c[a] = (c[a] || 0) + 1
        return c
    }
}

We perform a simple string check for the low straight and then switch the order of 'A' to 'N' for aces in this circumstance.

Collapse
gollyjer profile image
Jeremy Gollehon

Love this. Thanks! It's a really good problem to show off "modern" javascript.

Only piece of feedback... the nested ternary in byCountFirst is probably too terse.

Collapse
miketalbot profile image
Mike Talbot Author

Thanks! Yeah that's fair on the ternary. I'll adjust it.

Collapse
valemak_com profile image
Макаров Валерий

To take into account the "steel wheel" (straight 5432A) you need to add an imaginary face value of 1:

const order = "123456789TJQKA"

And if the input contains 2, 3, 4, 5 and A, then make the replacement of A by 1:

function SteelWheel(str) { if((str.indexOf('2') + 1) && (str.indexOf('3') + 1) && (str.indexOf('4') + 1) && str.indexOf('5') + 1) && (str.indexOf('A') + 1)) { return str.replace('A', '1') } else { return str } }

Collapse
Collapse
shopalino1 profile image