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 Choice
s 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:
pts1
, pts2
)pt1
, pt2
)mvs1
, mvs2
)mv1
, mv2
)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 seed
s) 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”