Our game is set up and ready to play! But now we face the challenge of needing to model how our players would play an actual game.

We can start solving that challenge by determining how players will keep track of the routes they want to claim, the color of the cards they need, and how they can actually make those claims. Let's get going!

I think I can I think I can I think I can I think I can! Photo by Michał Parzuchowski / Unsplash

The Sample Project

You might want to check out the sample repository over on GitHub to follow along with this post.

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

The Player's Choices

Let's remind ourselves what a player can do when it is their turn.

I swear, if you take that Nashville to Atlanta route again... DAMMIT KEVIN!

Players have three choices:

  1. The Player can claim an existing route. But in order for them to do this, they have to know what routes they want; they should not just claim random routes, because that won't help them win.
  2. The Player can draw new destination cards. But if they don't complete these cards, they count against the player's point total, so they player should not just draw these at any time.
  3. The Player can draw new train cards. But do they want to draw from the shown cards pile, or off the deck? To make this decision, they need to know what colors they want.

But there's also one more major feature we need in order to implement these three scenarios: we need to know what routes and colors the Player wants at any given time! We're going to call this Scenario 0.

We're going to tackle each of these scenarios separately. In this post, we will deal with Scenarios 0 and 1. The next post will deal with Scenarios 2 and 3.

The Basic Player Class

We first need a Player class. This object will have several properties, and all of them will be used in this post.

public class Player
{
    //The player's name
    public string Name { get; set; }

    //The player's current collection of destination cards.
    public List<DestinationCard> DestinationCards { get; set; } = new List<DestinationCard>();

    //The routes the player wants to claim at any given time.
    public List<BoardRoute> TargetedRoutes { get; set; } = new List<BoardRoute>();

    //All the cities this Player has already connected.
    public List<City> ConnectedCities { get; set; } = new List<City>();

    //The player's color (e.g. red, blue, black, green, or yellow)
    public PlayerColor Color { get; set; }

    //The Player's current collection of train cards.
    public List<TrainCard> Hand { get; set; } = new List<TrainCard>();

    //When one player has < 2 train cars the remaining, the final turn begins.
    public int RemainingTrainCars { get; set; } = 48;

    //The train card colors this Player wants to draw.
    public List<TrainColor> DesiredColors { get; set; } = new List<TrainColor>();

    //A reference to the game board
    public Board Board { get; set; }
    
    public void TakeTurn()
    {
        //...Implementation in this post and the next
    }
}

Scenario 0: Finding Targeted Routes

In order for the players to know what routes they'd like to claim, we are going to make them "target" these routes by storing a collection of them. Not surprisingly, this is the TargetedRoutes property in the base Player object.

Let's pretend our player starts the game with two destination cards: Duluth to Houston, and Winnipeg to New Orleans.

Can you tell me how to get, how to get to Sesame Street?

The ideal routes algorithm will find the following routes for these destination cards.

Duluth -> Omaha -> Kansas City -> Oklahoma City -> Dallas -> Houston

and

Winnipeg -> Duluth -> Omaha -> Kansas City -> Oklahoma City -> Little Rock -> New Orleans

Those two routes share a lot of the same route segments. Therefore, it is more important to the player to claim the ones that are shared amongst different destination card ideal routes, rather than routes that are only used for one card. We will need to make sure the "common" route segments are claimed first, lest another player claim them and our destination cards get more difficult to complete.

(IMPORTANT NOTE: There are many different ways we could say what is "important" to the player; claiming common routes is just one of them. For example, we could say that access to the cities is more important than common routes, and make the Player claim a route leading into the origin and destination cities before worrying about how to connect them. One of the reasons why Ticket to Ride is such a fun game is that there are multiple ways to assess the core problem of connecting city A to city B.)

To accomplish all this, let's create a CalculateTargetedRoutes() method in the Player object that accepts a Destination Card and returns the ideal routes for that card:

public class Player
{
    //...Properties

    public List<BoardRoute> CalculateTargetedRoutes(DestinationCard card)
    { }
}

Targeted Routes for Single Destination Card

In this method, we need to do the following things:

  1. Check to see if the cities in the Destination Card are already connected; if they are, there aren't any routes to target because this destination card is already complete!
  2. If the cities aren't connected, we will attempt to see if we can connect them from any other city we have already connected.
  3. If there's no way to connect the new cities from cities we already have connected, we will see if we can connect them using only unclaimed routes.
  4. If a route is included in the "ideal routes" list but we already claimed another route between the same two cities, we need to remove the duplicate route (as a single player cannot claim both routes between the same two cities).

Here's what this code looks like:

public List<BoardRoute> CalculateTargetedRoutes(DestinationCard card)
{
    var allRoutes = new List<BoardRoute>();

    //Step 1: Are the origin and destination already connected?
    if(Board.Routes.IsAlreadyConnected(card.Origin, card.Destination, Color))
    {
        return allRoutes;
    }
    Board.Routes.AlreadyCheckedCities.Clear();

    //Step 2: If the cities aren't already connected, 
    // attempt to connect them from something we've already connected.
    foreach(var city in ConnectedCities)
    {
        var foundDestinationRoutes = 
        	Board.Routes.FindIdealUnclaimedRoute(city, card.Destination);
            
        if(foundDestinationRoutes.Any())
        {
            allRoutes.AddRange(foundDestinationRoutes);
            break;
        }

        var foundOriginRoutes = 
            Board.Routes.FindIdealUnclaimedRoute(card.Origin, city);
            
        if(foundOriginRoutes.Any())
        {
            allRoutes.AddRange(foundOriginRoutes);
            break;
        }
    }

    //Step 3: If we can't connect them from something we have 
    //already connected, can we make a brand new connection?
    allRoutes = Board.Routes
                     .FindIdealUnclaimedRoute(card.Origin, card.Destination);

    //Step 4: If there is a duplicate route in our targeted routes,
    //remove it.
    var routesToRemove = new List<BoardRoute>();
    foreach(var route in allRoutes)
    {
        var matchingRoutes = Board.Routes.Routes
        .Where(x => x.Length == route.Length 
                    && x.IsOccupied && x.OccupyingPlayerColor == Color 
                    && ((x.Origin == route.Origin 
                    	 && x.Destination == route.Destination)
                       || (x.Origin == route.Destination 
                         && x.Destination == route.Origin)));

        if (matchingRoutes.Any())
            routesToRemove.Add(route);
    }

    foreach(var route in routesToRemove)
    {
        allRoutes.Remove(route);
    }

    return allRoutes;
}

For this single Destination Card, we now have the routes we want to target. Now, let's determine how to find the targetable routes for many Destination Cards.

Targeted Routes for Multiple Destination Cards

One way of finding out which routes are common for multiple destination cards is to get the collection of routes for each card separately and then find the intersections.

So, what we're going to do now is calculate the top five common routes, then have our player target the colors necessary to claim these routes.

public void CalculateTargetedRoutes()
{
    var allRoutes = new List<BoardRoute>();

    var highestCards = DestinationCards.OrderBy(x => x.PointValue).ToList();

    foreach(var destCard in highestCards)
    {
        var matchingRoutes = CalculateTargetedRoutes(destCard);
        if (matchingRoutes.Any())
        {
            allRoutes.AddRange(matchingRoutes);
            break;
        }
    }

    TargetedRoutes = allRoutes
         .GroupBy(x => new { x.Origin, x.Destination, x.Color, x.Length })
         .Select(group => new
         {
             Metric = group.Key,
             Count = group.Count()
         })
         .OrderByDescending(x => x.Count)
         .ThenByDescending(x => x.Metric.Length)
         .Take(5)
         .Select(x => new BoardRoute(x.Metric.Origin,
                                     x.Metric.Destination, 
                                     x.Metric.Color, 
                                     x.Metric.Length))
         .ToList();

    //This line becomes very important in the next post.
    DesiredColors = TargetedRoutes.Select(x => x.Color)
                                  .Distinct()
                                  .ToList();
}

Notice the ThenByDescending clause. By using this, we are telling the player to target longer routes first. If you want, you can use the sample project and change this to a ThenBy clause, and see how the behavior differs.

Because the targeted routes can change at any time (if, say, Alice claims a route that Charlie wanted), the first thing each player should do on their turn is calculate the routes they want, and the train colors they desire.

public class Player 
{
    //...Properties
    public void TakeTurn()
    {
        CalculateTargetedRoutes();
        
        //...Rest of implementation coming later.
    }
}

Scenario 1: Claim a Route

Now that the Player knows what routes they want, they should be able to claim a route if they have the appropriate train cards in their hand.

Claiming a route comes in two parts: determining if we have the cards to claim a route, and actually turning in those cards to do so.

Claiming A Route With A Specific Color

If the Player wishes to claim a route that has a color (e.g. green, white, purple, etc.) they must have some combination of that color and locomotive ("wild") cards that sum to the route's length.

We need a method which does the following:

  1. Takes a BoardRoute and a TrainColor and attempts to claim that route using that color.
  2. If there are not enough color cards, use locomotive cards to claim the route.
  3. Update the remaining number of train cars the player has.

Here's that method:

public bool ClaimRoute(BoardRoute route, TrainColor color)
{
    //If we don't have enough train cars remaining to claim this route, 
    //we cannot do so.
    if (route.Length > RemainingTrainCars)
        return false;

    //First, see if we have enough cards in the hand to claim this route.
    var colorCards = Hand.Where(x => x.Color == color).ToList();

    //If we don't have enough color cards for this route...
    if(colorCards.Count < route.Length)
    {
        //...see if we have enough Locomotive cards to fill the gap
        var gap = route.Length - colorCards.Count;
        var locomotiveCards = Hand.Where(x => 
                                         x.Color == TrainColor.Locomotive)
                                  .ToList();
                                  
        if(locomotiveCards.Count < gap)
        {
            return false; //Cannot claim this route.
        }

        var matchingWilds = Hand.GetMatching(TrainColor.Locomotive, gap);
        
        Board.DiscardPile.AddRange(matchingWilds);

        if (matchingWilds.Count != route.Length)
        {
            var matchingColors = Hand.GetMatching(colorCards.First().Color,
                                                  colorCards.Count);
            Board.DiscardPile.AddRange(matchingColors);
        }

        //This method call is, in effect, the same as placing the
        //Player's colored train cars on the route.
        Board.Routes.ClaimRoute(route, this.Color);

        //Add the cities to the list of connected cities
        ConnectedCities.Add(route.Origin);
        ConnectedCities.Add(route.Destination);

        ConnectedCities = ConnectedCities.Distinct().ToList();

        RemainingTrainCars = RemainingTrainCars - route.Length;

        Console.WriteLine(Name + " claims the route " 
                          + route.Origin + " to " + route.Destination + "!");

        //We successfully claimed this route!
        return true;
    }

    //If we only need color cards to claim this route, 
    // discard the appropriate number of them
    var neededColorCards = Hand.Where(x => x.Color == color)
                               .Take(route.Length).ToList();
                               
    foreach(var colorCard in neededColorCards)
    {
        Hand.Remove(colorCard);
        Board.DiscardPile.Add(colorCard);
    }

    //Mark the route as claimed on the board
    Board.Routes.ClaimRoute(route, this.Color);

    RemainingTrainCars = RemainingTrainCars - route.Length;

    Console.WriteLine(Name + " claims the route " 
                      + route.Origin + " to " + route.Destination + "!");

    //We successfully claimed this route!
    return true;
}

Claiming a Grey Route

There's a problem we haven't solved yet: what if the player wants to claim a Grey-colored route? Those routes require only that we use X number of cards in the same color, not a specific color.

To accomplish this, let's create a "wrapper" method which checks to see if we have a route we'd like to claim, and if that route is a Grey-colored route, that we have enough of one color card or locomotive cards to actually make the claim.

public bool TryClaimRoute()
{
    //How do we know if the player can claim a route they desire?
    //For each of the desired routes, loop through 
    //and see if the player has sufficient cards to claim the route.
    foreach(var route in TargetedRoutes)
    {
        //How many cards do we need for this route?
        var cardCount = route.Length;

        //If the route has a color, we need to use that color to claim it.
        var selectedColor = route.Color;

        //If the player is targeting a Grey route, they can use any color
        //as long as it's not their currently desired color.
        if (route.Color == TrainColor.Grey)
        {
            //Select all cards in hand that are not in our desired color 
            //AND are not locomotives.
            var matchingCard = Hand.Where(x => x.Color != TrainColor.Locomotive
                                          && !DesiredColors.Contains(x.Color))
                                   .GroupBy(x => x)
                                   .Select(group => new
                                   {
                                       Metric = group,
                                       Count = group.Count()
                                   })
                                   .OrderByDescending(x => x.Count)
                                   .Select(x => x.Metric.Key)
                                   .FirstOrDefault();

            if (matchingCard == null) continue;

            selectedColor = matchingCard.Color;

        }

        //Now attempt to claim the specified route with the selected color.
        return ClaimRoute(route, selectedColor);
    }

    return false;
}

We also need to update the TakeTurn() method:

public void TakeTurn()
{
    CalculateTargetedRoutes();

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

    if (hasClaimed)
        return;
        
    //...Rest of implementation coming in next post!
}

All right! Our players can now both target the routes they desire, and claim them when the have enough cards! We're making good progress!

Summary

In this fourth part of our Ticket to Ride C# Modeling Practice series, we saw how to implement logic in our Player classes to claim routes and calculate the ideal routes to target, as well as which colors the Player needs at any given time.

No funny comment here. I just like this train. Photo by Ankush Minda / Unsplash

In the next part, we will build out the Player class a bit more by writing logic to determine when a Player should draw new destination cards, or new train cards including whether or not to take a card from the Shown Cards collection.

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

Happy Coding!