20.2 Solution

Since this is a game theory problem, then we’re going to use game terminology in the solution. Ready. Let the games begin.

First, let’s define all the possible values for players (i.e. monkeys) and moves (i.e. choices) by using Julia’s enums.

@enum Player naive gullible unforgiving paybacker unfriendly abusive egoist
@enum Choice cooperate=0 betray=1

Now, whenever we use Player in our code (as a variable type) we will be able to use one of the seven informative and mnemonic names (naive gullible unforgiving paybacker unfriendly abusive egoist). The same goes for Choice our players will make (cooperate and betray). Note, however, that in this last case the Choices are followed by the number code. We didn’t have to do that since by default the enums are internally stored as consecutive integers that start at 0. Still, we did it to emphasize our plan to use this property of our new type in the near future. Namely, per problem description unforgiving monkey always betrays its partner when it was itself betrayed more than three times in the past. To that end we will need to count the betrayals.

import Base: +

function +(c1::Choice, c2::Choice)::Int
    return Int(c1) + Int(c2)
end

function +(n::Int, c::Choice)::Int
    return n + Int(c)
end

We start by importing the + function from Base package and make the versions of it (aka methods) that know how to handle our Choice enum. Simply, if we add two Choices (c1 and c2) together, we add the underlying integers (Int(c1) + Int(c2)) and when we add an integer (n) to a Choice then again we add the integer to the Choice’s integer representation (n + Int(c)). Thanks to this little trick, we will be able to count the total of betrayals in a vector of Choices with the built in sum function (it relies on +)

Note: Do not overuse this technique. In general, you should redefine the built in Base functions (like +) only on the types that you have defined yourself.

Time to write a function that will return the Player’s move. According to the problem description all it needs know to do its job correctly is the Player’s type and its opponents previous moves.

import Random as Rnd

function getMove(p::Player, opponentMoves::Vec{Choice})::Choice
    # random float in range [0.0-1.0), prob [0.0-1.0], so good enough
    prob::Flt = Rnd.rand()
    if p == naive
        return cooperate
    elseif p == unforgiving
        return sum(opponentMoves, init=0) > 3 ? betray : cooperate
    elseif p == gullible
        return prob <= 0.9 ? cooperate : betray
    elseif p == paybacker
        return isempty(opponentMoves) ? cooperate : opponentMoves[end]
    elseif p == unfriendly
        return prob <= 0.6 ? betray : cooperate
    elseif p == abusive
        return prob <= 0.8 ? betray : cooperate
    else # egoist player
        return betray
    end
end

The function is a bit cumbersome to type because Julia does not have a switch statement known from other programming languages. If you really must have it, then consider using Match.jl as a replacement. Anyway, the code is pretty simple if you are familiar with the decision making in Julia. One point to notice is that here we used the init=0 keyword argument in sum. This is a default value from which we start counting the total, and it makes sure that an empty vector (opponentMoves) returns 0 instead of an error when used with sum.

Time to award our players with survival points per a round (interaction) and their choices.

function getPts(c1::Choice, c2::Choice)::Tuple{Int, Int}
    if c1 == c2 == cooperate
        return (2, 2)
    elseif c1 > c2
        return (3, -2)
    elseif c1 < c2
        return (-2, 3)
    else # both betray
        return (-1, -1)
    end
end

Notice, that the enum defined by us (Choice) got a built in ordering that by default goes in an ascending order from left to right (cooperate < betray per @enum Choice cooperate betray) which we used to our advantage here.

Time to write a function that takes two players as an argument and runs a random number of games (50:300 interactions) between them. In the end it returns the survival points each player obtained.

function playRoundsGetPts(p1::Player, p2::Player)::Tuple{Int, Int}
    pts1::Int, pts2::Int = 0, 0 # total pts
    pt1::Int, pt2::Int = 0, 0 # pts per round
    mvs1::Vec{Choice}, mvs2::Vec{Choice} = [], [] # all moves
    mv1::Choice, mv2::Choice = cooperate, cooperate # move per round
    nRounds::Int = Rnd.rand(50:300)
    for _ in 1:nRounds
        mv1, mv2 = getMove(p1, mvs2), getMove(p2, mvs1)
        pt1, pt2 = getPts(mv1, mv2)
        push!(mvs1, mv1)
        push!(mvs2, mv2)
        pts1 += pt1
        pts2 += pt2
    end
    return (pts1, pts2)
end

We begin by defining and initializing variables to store:

  1. total number of points obtained by each player (pts1, pts2)
  2. the number of points obtained by the players per single interaction (pt1, pt2)
  3. all the moves made by the players during their interactions (mvs1, mvs2)
  4. the moves made by each player per single interaction (mv1, mv2)
  5. the number of interactions (rounds) between the players (nRounds)

We update the above mentioned variables after every round/interaction took place (in the for loop). Finally, we return the number of points obtained by each player.

Time to set things into motion and make all the players play with each other.

function playGame(players::Vec{Player})::Dict{Player, Int}
    playersPts::Dict{Player, Int} = Dict(p => 0 for p in players)
    alreadyPlayed::Dict{Player, Bool} = Dict()
    for player1 in players, player2 in players
        if player1 == player2 || haskey(alreadyPlayed, player2)
            continue
        end
        pts1, pts2 = playRoundsGetPts(player1, player2)
        playersPts[player1] += pts1
        playersPts[player2] += pts2
        alreadyPlayed[player1] = true
    end
    return playersPts
end

Again, we start by initializing the necessary variables: the result (playersPts) and players that already played in our game (alreadyPlayed). Next, we use Julia’s simplified nested for loop syntax (that we met in Section 2.2) to make all players play with each other. We prevent the player playing with themselves (player1 == player2). We also stop the players from playing with each other two times. Without haskey(alreadyPlayed, player2), e.g. naive would play with egoist twice [once as player1 (naive vs egoist), the other time as player2 (egoist vs naive)]. We update the points scored by each player after every pairing (playerPts[player1] += pts1, playerPts[player2] += pts2) and return them as a result (return playersPts).

Let’s see how it works.

Rnd.seed!(401) # needed to make it reproducible
playGame([naive, unforgiving, paybacker, unfriendly, abusive, egoist])
Dict{Player, Int64} with 6 entries:
  unforgiving => 490
  paybacker => 464
  egoist => 323
  unfriendly => -26
  naive => 68
  abusive => 2

First three competitors (monkeys) are: unforgiving followed by paybacker and egoist. Run the simulation a couple of times (with different seeds) and see the results. In general the good players (monkeys) win the podium with the evil ones in 2:1 ratio.

Interestingly, if we replace the unforgiving with gullible (it cooperates at random 90% of the times) we get something entirely different.

Rnd.seed!(401) # needed to make it reproducible
playGame([naive, gullible, paybacker, unfriendly, abusive, egoist])
Dict{Player, Int64} with 6 entries:
  gullible => 79
  paybacker => 395
  egoist => 615
  unfriendly => 758
  naive => 36
  abusive => 326

The situation seems to be reversed, The evil players (monkeys) win the podium with the good ones in 2:1 ratio. So I guess: “The only thing necessary for evil to triumph in the world is that good men do nothing



CC BY-NC-SA 4.0 Bartlomiej Lukaszuk