Our players have the ability to claim routes and determine which ones they want, but they cannot yet draw cards efficiently. In this post, we will make them have the ability to both draw new destination cards when they need them, and draw new train cards according to their desired colors.

Not THAT kind of drawing! Photo by Neven Krcmarek / Unsplash

The Sample Project

As always with my code-heavy posts, there's a sample project on GitHub that you can use to follow along with this one. Check it out!

exceptionnotfound/TicketToRideModelingPractice
Contribute to exceptionnotfound/TicketToRideModelingPractice development by creating an account on GitHub.

Drawing Destination Cards

In a real game of Ticket to Ride, you can draw new destination cards at any time.

But drawing new destination cards is most commonly done when at least one of the player's current destination cards is completed. Newer players will most likely wait until all their current cards are either completed or impossible to finish.

So, our players will draw destination cards under the following circumstances:

  1. All their current destination cards are either complete or impossible to finish AND
  2. They currently have 5 or less destination cards already.

The second condition is to prevent the game from running out of destination cards.

We know from calculating the targeted routes that if no targeted routes are found, then the destination cards the player already has are completed or not completable. So, all we do is instruct our player that, if there are no targeted routes for him/her, they should draw destination cards.

public class Player
{
    //...Properties and Other Methods
    public void TakeTurn()
    {
        CalculateTargetedRoutes();

        //The calculation for Desired Routes only returned routes 
        //that can still be claimed.
        //If there are no desired routes, then all routes 
        //the player wants are already claimed
        //(whether by this player or someone else).
        //Therefore, if there are no desired routes, draw new destination cards
        if (!TargetedRoutes.Any())
        {
            if (!Board.DestinationCards.Any())
            {
                Console.WriteLine("No destination cards remain! " 
                                  + Name + " must do something else!");
            }
            else if (DestinationCards.Count < 5)
            {
                DrawDestinationCards(); //This method is implemented later
                                        //in this post.
                return;
            }
        }
    }
}

But exactly how does the player draw new destination cards?

The rules of Ticket to Ride state that when a player does this, they will get three destination cards. They must keep one of them, but they can keep as many as they like.

For our simulation, we're going to say that the player will only ever keep one of the three destination cards they are dealt, and will return the other two to the pile. The question is: which one?

Some of the criteria here are obvious. For example, if the player is lucky and draws a destination card they have already completed, obviously they'll keep that one because it is essentially free points. But in order to check if the route is already connected, we'll need several new methods in the BoardRouteCollection class.

Modifications to BoardRouteCollection

The first new method we need is very similar to one we have already written: we need a variation of the GetConnectedCities method that only returns cities that are connected by a given player. We also need a new method to check for a direct route between two cities.

public List<CityLength> GetConnectingCitiesForPlayer(City origin, PlayerColor color)
{
    var destinations = Routes.Where(x => x.Origin == origin 
                                         && x.IsOccupied 
                                         && x.OccupyingPlayerColor == color)
                             .Select(x => new CityLength() 
                             { 
                                 City = x.Destination, 
                                 Length = x.Length 
                             })
                             .ToList();
                             
    var origins = Routes.Where(x => x.Destination == origin
                                    && x.IsOccupied 
                                    && x.OccupyingPlayerColor == color)
                        .Select(x => new CityLength() 
                        { 
                            City = x.Origin, 
                            Length = x.Length 
                        })
                        .ToList();

    destinations.AddRange(origins);
    return destinations.Distinct().OrderBy(x => x.Length).ToList();
}

public BoardRoute GetDirectRouteForPlayer(City origin, 
                                          City destination, 
                                          PlayerColor color)
{
    var route = Routes.Where(x => (x.Origin == origin 
                                   && x.Destination == destination 
                                   && x.IsOccupied 
                                   && x.OccupyingPlayerColor == color)
                               || (x.Origin == destination 
                                   && x.Destination == origin 
                                   && x.IsOccupied
                                   && x.OccupyingPlayerColor == color))
                        .FirstOrDefault();

    return route;
}

This pattern should look familiar, as we did something very close to this to find the ideal routes back in Part 3.

We also need a brand new method that uses the new GetConnectingCitiesForPlayer() and GetDirectRouteForPlayer() methods to see if two cities are connected by a given player. We call that method IsAlreadyConnected().

public bool IsAlreadyConnected(City origin, City destination, PlayerColor color)
{
    List<BoardRoute> returnRoutes = new List<BoardRoute>();

    //A city is already connected to itself.
    if (origin == destination)
    {
        return true;
    }
 
    //Get next-order connected origin cities for the given player
    var masterOriginList = GetConnectingCitiesForPlayer(origin, color);
    
    //Get next-order connected destination cities for the given player
    var masterDestinationList = GetConnectingCitiesForPlayer(destination, color);

    //If these methods return no routes, 
    //there are no possible routes to finish. 
    //So, we return an empty list
    if (!masterOriginList.Any() || !masterDestinationList.Any())
    {
        return false;
    }

    var originCitiesList = masterOriginList.Select(x => x.City);
    var destCitiesList = masterDestinationList.Select(x => x.City);

    bool targetOrigin = true;

    //Step through the connected cities to find their next-order
    //destinations.
    while (!originCitiesList.Intersect(destCitiesList).Any() 
           && originCitiesList.Count() < 500)
    {
        if (targetOrigin == true)
        {
            var copyMaster = new List<City>(originCitiesList);
            foreach (var originCity in copyMaster)
            {
                masterOriginList.AddRange(
                    GetConnectingCitiesForPlayer(originCity, color)
                );
            }
        }
        else
        {
            var copyMaster = new List<City>(destCitiesList);
            foreach (var destCity in copyMaster)
            {
                masterDestinationList.AddRange(
                    GetConnectingCitiesForPlayer(destCity, color)
                );
            }
        }
        targetOrigin = !targetOrigin;
    }

    //It is possible that there are no connecting routes left.
    //In that case, the collections for master cities get very large
    //If they get very large, assume no connections exist.
    if (originCitiesList.Count() >= 500)
    {
        return false;
    }

    //If a midpoint exists, find it
    var midpointCity = originCitiesList.Intersect(destCitiesList).First();

    //Check for direct routes between the origin and midpoint
    var originDirectRoute = GetDirectRouteForPlayer(origin, midpointCity, color);
    
    //Check for direct routes between midpoint and destination
    var destinationDirectRoute 
        = GetDirectRouteForPlayer(midpointCity, destination, color);

    if (originDirectRoute != null)
    {
        returnRoutes.Add(originDirectRoute);
    }

    if (destinationDirectRoute != null)
    {
        returnRoutes.Add(destinationDirectRoute);
    }

    //If no direct route from origin to midpoint is found, recursively
    //call this method to find multiple routes between origin
    //and midpoint.
    if (originDirectRoute == null)
    {
        return false || IsAlreadyConnected(origin, midpointCity, color);
    }

    //If no direct route from midpoint to destination is found, recursively
    //call this method to find multiple routes between midpoint
    //and destination.
    if (destinationDirectRoute == null)
    {
        return false || IsAlreadyConnected(midpointCity, destination, color);
    }

    return returnRoutes.Any();
}

Now we can finally work on the player logic for this situation.

public void DrawDestinationCards()
{
    var tempDestinationCards = 
        (Board.DestinationCards.Pop(3)).OrderByDescending(x => x.PointValue);

    //For each of these cards, keep only the one 
    //that's either complete or is completable.

    List<DestinationCard> discardCards = new List<DestinationCard>();
    List<DestinationCard> keptCards = new List<DestinationCard>();
    foreach(var card in tempDestinationCards)
    {
        //Keep cards that are already connected
        if (Board.Routes
                 .IsAlreadyConnected(card.Origin, card.Destination, Color))
        {
            keptCards.Add(card);
            continue;
        }

        Board.Routes.AlreadyCheckedCities.Clear();

        var possibleRoutes = CalculateTargetedRoutes(card);
        
        if (!possibleRoutes.Any())
            discardCards.Add(card);
        else
            keptCards.Add(card);
    }

    //If there are no kept cards, the player must keep at least one, 
    //so keep the one with the lowest point value.
    if(!keptCards.Any())
    {
        discardCards = discardCards.OrderBy(x => x.PointValue).ToList();
        keptCards.AddRange(discardCards.Pop(1));
    }

    //Return the discarded cards to the Destination Cards deck.
    if(discardCards.Any())
    {
        //Return the discarded cards to the destination card pile
        Board.DestinationCards.AddRange(discardCards);
    }

    //Add the kept cards to the Player's collection.
    DestinationCards.AddRange(keptCards);

    Console.WriteLine(Name + " draws new destination cards!");

    return;
}

Excellent! Our Players can now draw destination cards, determine which is the best for them to keep, and return the undesirable ones to the deck!

The last part of the Player behavior we need to work out is how to draw train cards. Luckily, part of this is already done for us.

A Reminder

Recall that when we were calculating the targeted routes, we also determine the colors the Player needs to target in order to claim those routes; the colors were stored in the property DesiredColors.

A Couple of Caveats

There's a couple of caveats we need to state here. First, it is entirely possible for the game to run our of train cards and crash. To prevent that, we will force our players to claim a route (using cards that are not part of their desired colors) if the player's train card count goes above 24, or one-quarter of the deck.

Second, if the player is attempting to claim a grey-colored route, they will try to select from the Shown Cards the color they have the most of that is not one of their desired colors. This is so that the cards that are desired colors can be used to claim the routes the player is aiming for.

Finally, drawing new train cards needs to happen if and only if the player neither wants to draw new destination cards nor is able to claim a route.

Modifications to TakeTurn()

OK, with those out of the way, let's get started!

public void TakeTurn()
{
    CalculateTargetedRoutes();

    //The calculation for Desired Routes only returned routes 
    //that can still be claimed.
    //If there are no desired routes, 
    //then all routes the player wants are already claimed
    //(whether by this player or someone else).
    //Therefore, if there are no desired routes, draw new destination cards
    if (!TargetedRoutes.Any())
    {
        if (!Board.DestinationCards.Any())
        {
            Console.WriteLine("No destination cards remain! " 
                              + Name + " must do something else!");
        }
        else if (DestinationCards.Count < 5)
        {
            DrawDestinationCards();
            return;
        }
    }

    //If the player can claim a route they desire, they will do so immediately.
    var hasClaimed = TryClaimRoute();

    if (hasClaimed)
        return;

    //We now have a problem. It is possible for a player 
    //to have a lot of train cards. 
    //So, let's have them claim the longest route they can claim
    //with the cards they have available, if they have more than 24 cards.
    if (Hand.Count >= 24)
    {
        ClaimLongestUnclaimedRoute(); //This method is implemented 
                                      //later in this post.
    }
    else
    {
        DrawCards(); //This method is implemented immediately below.
    }
}

A Special Extension Method

Let's talk about the special scenario where the targeted route for the player is a grey-colored route. In this case, they want to draw the color cards that they already have the most of, so as to claim the route sooner. To allow for this, we create an extension method that finds the most popular color in the Player's hand that is not a desired color:

public static class CardExtensions
{
    public static TrainColor GetMostPopularColor(this List<TrainCard> cards, List<TrainColor> desiredColors)
    {
        if (!cards.Any())
            return TrainColor.Locomotive;

        var colors = cards.Where(x => x.Color != TrainColor.Locomotive)
                    .GroupBy(x => x.Color)
                    .Select(group =>
                    new
                    {
                        Color = group.Key,
                        Count = group.Count()
                    })
                    .OrderByDescending(x => x.Count)
                    .Select(x=>x.Color)
                    .ToList();
        var selectedColor = colors.First();

        var otherColors = colors.Except(desiredColors);
        if (otherColors.Any())
        {
            while (desiredColors.Contains(selectedColor))
            {
                colors.Remove(selectedColor);
                selectedColor = colors.First();
            }
        }

        return selectedColor;
    }
}

(Finally) Implementing DrawCards()

Let's talk about how to implement that DrawCards() method.

If a player wants to draw train cards, they will take any card in the Shown Cards that matches their desired color. There might be two, there might be only one, there might not be any. They will also take a locomotive card if there is one showing, and no other desired colors are showing.

We can implement all of these options like this:

public void DrawCards()
{
    //If the player wants a grey route, 
    //they will also be able to take whatever they have the most of already
    if (DesiredColors.Contains(TrainColor.Grey))
    {
        var mostPopularColor = Hand.GetMostPopularColor(DesiredColors);
        DesiredColors.Add(mostPopularColor);
        DesiredColors.Remove(TrainColor.Grey);
    }

    //Check the desired colors against the shown cards
    var shownColors = Board.ShownCards.Select(x=>x.Color);
    var matchingColors = DesiredColors.Intersect(shownColors).ToList();

    var desiredCards = Board.ShownCards.Where(x =>                 
                                              DesiredColors.Contains(x.Color));

    if (matchingColors.Count() >= 2) //If two desired colors are shown
    {
        //Take the cards and add them to the hand
        var cards = desiredCards.Take(2).ToList();
        foreach(var card in cards)
        {
            Console.WriteLine(Name + " takes the shown " 
                              + card.Color + " card.");
            Board.ShownCards.Remove(card);
            Hand.Add(card);
        }
    }
    else if (matchingColors.Count() == 1) //If only one desired color is shown
    {
        var card = desiredCards.First();
        Board.ShownCards.Remove(card);
        Hand.Add(card);

        Console.WriteLine(Name + " takes the shown " + card.Color + " card.");

        //Also draw one from the deck
        var deckCard = Board.Deck.Pop(1).First();
        Hand.Add(deckCard);
        Console.WriteLine(Name + " also draws one card from the deck.");
    }
    //If a locomotive is shown and no desired colors are.
    else if (matchingColors.Count() == 0 
             && Board.ShownCards.Any(x => x.Color == TrainColor.Locomotive))
    {
        Console.WriteLine(Name + " takes the shown locomotive.");

        var card = Board.ShownCards.First(x =>
                                          x.Color == TrainColor.Locomotive);
        Board.ShownCards.Remove(card);
        Hand.Add(card);
    }
    else //No other options remain, draw two from the deck
    {
        Console.WriteLine(Name + " draws two cards from the deck.");
        
        var cards = Board.Deck.Pop(2);
        Hand.AddRange(cards);
    }

    Board.PopulateShownCards();
}

Now the player can draw cards on their turn! But there's one last thing we need to write: if the player gets too many train cards, they should claim a route to get rid of some.

Claiming the Longest Available Route

More specifically, they should claim the longest route they can without using their desired colors. For this method, we use the GetMostPopularColor extensions we wrote earlier, and claim a route with that color:

public void ClaimLongestUnclaimedRoute()
{
    //Find the color we have the most of, 
    //so long as it isn't one of the desired colors.
    var mostPopularColor = Hand.GetMostPopularColor(DesiredColors);

    //Now, find a route for that color
    var matchingRoute = Board.Routes
                             .Routes
                             .Where(x => !x.IsOccupied
                                         && (x.Color == mostPopularColor
                                             || x.Color == TrainColor.Grey))
                             .OrderByDescending(x => x.Length)
                             .FirstOrDefault();

    if(matchingRoute != null)
    {
        ClaimRoute(matchingRoute, mostPopularColor);
    }
}

Very cool! We have fully implemented our player behavior!

Summary

In this part of our Ticket to Ride C# Modeling Practice series, we finished our player behavior by implementing logic to draw new Destination Cards, target and draw Train Cards and claim routes when our hand gets too big.

Chugging right along. Photo by Jack Anstey / Unsplash

In the next and final part of this C# Modeling Practice series, we will finish our implementation and run some examples! Stick around!

Don't forget to check out the sample project over on GitHub!

Happy Coding!