Powered by AppSignal & Oban Pro

Horse Game

Elixir/horse-game.livemd

Horse Game

Mix.install([
  {:kino, "~> 0.14.2"},
  {:kino_explorer, "~> 0.1.23"}
])

Functions

defmodule HorseGame do
  require Explorer.DataFrame, as: DF
  alias Explorer.Series

  @horses Enum.to_list(2..12)

  # number of board spaces for each horse (2–12)
  @steps [3, 6, 8, 11, 14, 16, 14, 11, 8, 6, 3]
  @steps_map Map.new(Enum.zip(@horses, @steps))

  # number of the 36 dice combinations that give each roll (2–12)
  @counts (for d1 <- 1..6, d2 <- 1..6, do: d1 + d2)
          |> Enum.frequencies()
          |> then(fn freq -> Enum.map(@horses, &freq[&1]) end)

  @probs Enum.map(@counts, &(&1 / Enum.sum(@counts)))
  @weighted Enum.zip(@horses, @probs)

  @horse_colors %{
    2 => "#a6cee3",
    12 => "#1f78b4",
    3 => "#b2df8a",
    11 => "#33a02c",
    4 => "#fb9a99",
    10 => "#e31a1c",
    5 => "#fdbf6f",
    9 => "#ff7f00",
    6 => "#cab2d6",
    8 => "#6a3d9a",
    7 => "#b15928"
  }

  def horses, do: @horses
  def horse_colors, do: @horse_colors

  def rolls_df do
    DF.join(DF.new(dice1: 1..6), DF.new(dice2: 1..6), how: :cross)
    |> DF.mutate(roll: dice1 + dice2)
    |> DF.group_by("roll")
    |> DF.summarise(n: size(roll))
    # summarise does not preserve group order; sort before putting steps by position
    |> DF.sort_by(roll)
    |> DF.put(:steps, Series.from_list(@steps))
    |> DF.mutate(prob: n / sum(n), prob_steps: steps / sum(steps))
  end

  def roll(n, replace \\ true, prob \\ @probs)

  def roll(n, true, prob) do
    weighted = Enum.zip(@horses, prob)
    for _ <- 1..n//1, do: sample_one(weighted)
  end

  # weighted sampling without replacement: draw one, remove it, repeat
  def roll(n, false, prob) when n <= 11 do
    {picks, _} =
      Enum.reduce(1..n//1, {[], Enum.zip(@horses, prob)}, fn _, {picks, weighted} ->
        x = sample_one(weighted)
        {[x | picks], List.keydelete(weighted, x, 0)}
      end)

    Enum.reverse(picks)
  end

  defp sample_one(weighted) do
    total = weighted |> Enum.map(&elem(&1, 1)) |> Enum.sum()
    pick(weighted, :rand.uniform() * total)
  end

  defp pick([{x, _}], _target), do: x
  defp pick([{x, p} | _rest], target) when target < p, do: x
  defp pick([{_, p} | rest], target), do: pick(rest, target - p)

  def get_kitty(base_value, scratches, rolls \\ []) do
    # multiply by 4 for 4 suits in deck
    init = 4 * base_value * (4 + 3 + 2 + 1)
    # position in scratches (1-based) is the multiplier
    mults = scratches |> Enum.with_index(1) |> Map.new()
    init + (rolls |> Enum.map(&(Map.get(mults, &1, 0) * base_value)) |> Enum.sum())
  end

  # map of horse => remaining steps; scratched horses are nil
  def get_steps_remain(scratches, rolls) do
    counts = Enum.frequencies(rolls)

    Map.new(@horses, fn h ->
      {h, if(h in scratches, do: nil, else: @steps_map[h] - Map.get(counts, h, 0))}
    end)
  end

  def sim_one_game(scratches, rolls, winner_only \\ true) do
    active =
      scratches
      |> get_steps_remain(rolls)
      |> Map.reject(fn {_, v} -> is_nil(v) end)

    if Enum.any?(active, fn {_, v} -> v < 0 end) do
      raise "Winner was already determined"
    end

    {winner, sim_rolls} = race(active, [])

    if winner_only do
      winner
    else
      %{winner: winner, rolls: rolls ++ sim_rolls}
    end
  end

  # keep rolling until an active horse has 0 steps remaining
  defp race(steps_remain, sim_rolls) do
    case Enum.find(steps_remain, fn {_, v} -> v == 0 end) do
      {winner, 0} ->
        {winner, Enum.reverse(sim_rolls)}

      nil ->
        r = sample_one(@weighted)

        steps_remain =
          if Map.has_key?(steps_remain, r),
            do: Map.update!(steps_remain, r, &(&1 - 1)),
            else: steps_remain

        race(steps_remain, [r | sim_rolls])
    end
  end

  # simulates win probability from any point in game (based on rolls)
  def sim_win_prob(scratches, rolls \\ [], n_sim \\ 1000) do
    freq =
      1..n_sim
      |> Enum.map(fn _ -> sim_one_game(scratches, rolls) end)
      |> Enum.frequencies()

    Map.new(@horses, fn h -> {h, Map.get(freq, h, 0) / n_sim} end)
  end

  # returns winner and kitty; always starting at beginning of game
  def sim_new_game(scratches, base_value \\ 0.25) do
    %{winner: winner, rolls: rolls} = sim_one_game(scratches, [], false)
    %{winner: winner, kitty: get_kitty(base_value, scratches, rolls)}
  end
end
HorseGame.rolls_df()

Simulation

require Explorer.DataFrame, as: DF

n_reps = 100_000
base_value = 0.25

sim_list =
  Task.async_stream(
    1..n_reps,
    fn _ -> HorseGame.sim_new_game(HorseGame.roll(4, false), base_value) end,
    ordered: false
  )
  |> Enum.map(fn {:ok, sim} -> sim end)

sim_df =
  DF.new(
    sim: 1..n_reps,
    winner: Enum.map(sim_list, & &1.winner),
    kitty: Enum.map(sim_list, & &1.kitty)
  )
# order reflects paired probabilities
winner_levels = Enum.reverse([2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7])
levels_df = DF.new(winner: winner_levels, level: 0..10)

winner_counts = sim_df |> DF.pull(:winner) |> Explorer.Series.to_list() |> Enum.frequencies()

n = Enum.map(winner_levels, &Map.get(winner_counts, &1, 0))

winners =
  DF.new(
    winner: winner_levels,
    n: n,
    percent: Enum.map(n, &Float.round(&1 / n_reps * 100, 2))
  )
kitty_avg =
  sim_df
  |> DF.group_by(:winner)
  |> DF.summarise(kitty_avg: mean(kitty))
  |> DF.join(levels_df)
  |> DF.sort_by(level)
  |> DF.discard(:level)