Skip to content

Commit

Permalink
refactor(v2): implement purely functional core (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmkontra authored Jul 26, 2021
1 parent def466c commit aa6f046
Show file tree
Hide file tree
Showing 38 changed files with 1,085 additions and 66 deletions.
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"elixirLS.projectDir": "./bullion",

}
38 changes: 27 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,39 @@ poker banking automation

Home poker games become tedious to track: who had how many chips, and how much are those chips worth?

With that problem in hand, I set out to see just how quickly I could stand up a web application.

# Overview

This app implements a minimal full-stack web app that allows the user to start a `game` specifying a buyin cash amount and corresponding chip count. Then `players` are added to the game. Each buyin can be tracked and players can cash out one or more times. Their corresponding balance is reported.

The player balances are reported from the perspective of the banker (i.e. negative balance means the player is owed money).
This app implements a minimal full-stack web app that allows the user to start a `table` specifying a `buyin` cash amount and corresponding chip count. `Players` can be added to the game. Each `buyin` can be tracked and players can `cashout` one or more times. Their corresponding balance is reported.

Games are stored with a serial primary key that is hashed to a `shortcode`. This shortcode can be used to lookup your games if you navigate away. The url structure allows you to bookmark games.
Tables are assigned a `TableID` that can be used to return to a game if you navigate away from it.

# Architecture

- phoenix elixir app
- postgres database
- simple phoenix template html
- deployed via digital ocean server
V2 Architecture:
- Bullion
- Phoenix web layer (`BullionWeb.V2Controller`)
- Ecto (Postgres) persistence layer (`Bullion.TableV2`)
- BullionCore
- Purely functional `Table` struct
- count of buyins for each player (i.e. Tyler has bought in 3 times)
- list of cashouts for each player (i.e. 100 chips, and then later 75 more chips)
- `TableServer` GenServer managing the state of a table
- `TableSupervisor` Supervision managing each table as a process
- `BullionCore` does not implement persistence, but accepts dependency-injected callbacks for each of the 5 persistence methods
- create a table
- add a player to a table
- add a buyin to a player
- record a cashout for a player
- lookup the table state (to rehydrate a GenServer, for example)

Legacy Architecture (available at `/legacy` url prefix):
- Phoenix web application
- Ecto/Postgres persistence
- tight coupling of Ecto schemas and application logic

# Roadmap

Add client-side sessions (cookies) to provide a `Your Recent Games` section on the home page to make it easy to return to previously reviewed games (without remembering a shortcode).
- Read-only table view for the V2 application
- alternatively, "close" a table so that it can no longer be modified, and then the game can be shared
- Client-side sessions (cookies) to provide a `Your Recent Games` section on the home page
- this will make it easy to return to previously reviewed games (without remembering a table id).
4 changes: 4 additions & 0 deletions bullion-core/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
26 changes: 26 additions & 0 deletions bullion-core/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
bullion_core-*.tar

# Temporary files, for example, from tests.
/tmp/
21 changes: 21 additions & 0 deletions bullion-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# BullionCore

**TODO: Add description**

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `bullion_core` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:bullion_core, "~> 0.1.0"}
]
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/bullion_core](https://hexdocs.pm/bullion_core).

40 changes: 40 additions & 0 deletions bullion-core/lib/bullion_core.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
defmodule BullionCore do
@moduledoc """
Documentation for `BullionCore`.
"""

alias BullionCore.Table

@table_lookup_fn Application.fetch_env!(:bullion_core, :table_lookup_fn)
@save_new_table_fn Application.fetch_env!(:bullion_core, :save_new_table_fn)
@save_new_player_fn Application.fetch_env!(:bullion_core, :save_new_player_fn)
@save_buyin_fn Application.fetch_env!(:bullion_core, :save_buyin_fn)
@save_cashout_fn Application.fetch_env!(:bullion_core, :save_cashout_fn)

def hello do
:world
end

@spec table_lookup(binary(), (binary -> Table | nil)) :: Table | nil
def table_lookup(table_id, lookup_fn \\ @table_lookup_fn) when is_binary(table_id) do
lookup_fn.(table_id)
end

def save_new_table(%Table{} = table, save_fn \\ @save_new_table_fn) do
save_fn.(table)
end

@spec save_player(any, any, (any, any -> any)) :: any
def save_player(table, player, save_fn \\ @save_new_player_fn) do
save_fn.(table, player)
end

def save_buyin(table_id, player_id, save_fn \\ @save_buyin_fn) do
save_fn.(to_string(table_id), to_string(player_id))
end

def save_cashout(table_id, player_id, chip_count, save_fn \\ @save_cashout_fn) do
save_fn.(table_id, player_id, chip_count)
end

end
22 changes: 22 additions & 0 deletions bullion-core/lib/bullion_core/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule BullionCore.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false

use Application

@impl true
def start(_type, _args) do
children = [
# Starts a worker by calling: BullionCore.Worker.start_link(arg)
# {BullionCore.Worker, arg}
{Registry, keys: :unique, name: Registry.Table},
BullionCore.TableSupervisor
]

# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: BullionCore.Supervisor]
Supervisor.start_link(children, opts)
end
end
7 changes: 7 additions & 0 deletions bullion-core/lib/bullion_core/player.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule BullionCore.Player do
defstruct ~w[id name]a

def new(id, name) do
%__MODULE__{id: id, name: name}
end
end
123 changes: 123 additions & 0 deletions bullion-core/lib/bullion_core/table.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
defmodule BullionCore.Table do
defstruct ~w[id name buyin_dollars buyin_chips players buys cashouts]a

alias BullionCore.Player

def new(fields) do
defaults = %{players: [], buys: %{}, cashouts: %{}}
fields = Map.merge(fields, defaults)
struct!(__MODULE__, fields)
end

def generate_table_id(_args) do
Base.encode16(:crypto.strong_rand_bytes(8))
end

defp generate_player_id(_args \\ []) do
Base.encode16(:crypto.strong_rand_bytes(6))
end

def add_player(table, name) do
new_id = generate_player_id()
player = Player.new(new_id, name)
players = [player | table.players]
{player, %{table | players: players}}
end

def buyin(table, player_id) do
with {:ok, p} <- get_player(table, player_id),
table <- add_buy(table, player_id)
do
{p, table}
end
end

def cashout(table, player_id, chips) do
with {:ok, p} <- get_player(table, player_id),
table <- add_cashout(table, player_id, chips)
do
{p, table}
end
end

defp add_cashout(table, player_id, chips) do
existing_cashouts = Map.get(table.cashouts, player_id, [])
player_cashouts = [chips | existing_cashouts]
cashouts = Map.put(table.cashouts, player_id, player_cashouts)
%{table | cashouts: cashouts}
end

defp add_buy(table, player_id) do
existing_buys = Map.get(table.buys, player_id, 0)
buys = Map.put(table.buys, player_id, existing_buys + 1)
%{table | buys: buys}
end

def get_player(table, player_id) do
table.players
|> Enum.find(fn p -> p.id == player_id end)
|> case do
nil -> {:error, "not found"}
p -> {:ok, p}
end
end

def total_buyins(table) do
table.buys
|> Enum.map(fn({_k, v}) -> v end)
|> Enum.sum
end

def player_balance(table, player_id) do
{:ok, p} = get_player(table, player_id)
dpc = dollars_per_chip(table)
buys = Map.get(table.buys, player_id, 0)
cashouts = Map.get(table.cashouts, player_id, [])
remaining = outstanding_chips(table.buyin_chips, buys, cashouts)
chips_purchased = buys * table.buyin_chips
chips_returned = Enum.sum(cashouts)
chip_balance = chips_purchased - chips_returned
chip_value = chip_balance * dpc
buyin_total = buys * table.buyin_dollars
{buys, cashouts, remaining, chip_value, buyin_total}
end

def player_views(table) do
for player <- table.players do
{buys, cashouts, remaining, chip_value, buyin_total} = balance = player_balance(table, player.id)
bank = if chip_value < 0 do
{:owed, -chip_value}
else
{:owes, chip_value}
end
{player, buys, Enum.sum(cashouts), remaining, bank}
end
end

def balance_sheet(table) do
total_buys = total_buyins(table)
total_out = player_views(table)
|> Enum.reduce(0, fn ({_, _, _, outstanding, _}, acc) -> acc + outstanding end)
{owed, owes} = player_views(table)
|> Enum.map(fn {_, _, _, _, bank} -> bank end)
|> Enum.split_with(fn {owe?, value} -> owe? == :owed end)
owes = owes |> Enum.reduce(0, fn ({_, value}, acc) -> acc + value end) |> Decimal.from_float() |> Decimal.round(2)
owed = owed |> Enum.reduce(0, fn ({_, value}, acc) -> acc + value end) |> Decimal.from_float() |> Decimal.round(2)
{total_buys, total_out, owes, owed}
end

defp dollars_per_chip(table) do
table.buyin_dollars / table.buyin_chips
end

defp outstanding_chips(buyin_chips, buys, cashouts) do
purchased_chips = buys * buyin_chips
returned_chips = cashouts |> Enum.sum
purchased_chips - returned_chips
end

defp match_player_id(player_id, {plid, _v}) do
plid == player_id
end

end
77 changes: 77 additions & 0 deletions bullion-core/lib/bullion_core/table_server.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule BullionCore.TableServer do
use GenServer, start: {__MODULE__, :start_link, []}, restart: :permanent

alias BullionCore.Table
alias BullionCore

def start_link({table_name, buyin_chips, buyin_dollars} = _args) when is_binary(table_name) and is_integer(buyin_chips) and is_number(buyin_dollars) do
table_id = Table.generate_table_id([])
GenServer.start_link(
__MODULE__,
{table_id, table_name, buyin_chips, buyin_dollars},
name: via(table_id)
)
end

def start_link(%Table{} = existing_table) do
GenServer.start_link(
__MODULE__,
existing_table,
name: via(existing_table.id)
)
end

@spec via(any) :: {:via, Registry, {Registry.Table, any}}
def via(table_id), do: {:via, Registry, {Registry.Table, table_id}}

def init({table_id, table_name, buyin_chips, buyin_dollars}) do
table = Table.new(%{id: table_id, name: table_name, buyin_dollars: buyin_dollars, buyin_chips: buyin_chips})
BullionCore.save_new_table(table)
{:ok, table}
end

def init(%Table{} = existing_table) do
{:ok, existing_table}
end

def view_table(table) do
GenServer.call(table, :view_table)
end

def add_player(table, player_name) when is_binary(player_name) do
GenServer.call(table, {:add_player, player_name})
end

def player_buyin(table, player_id) when is_binary(player_id) do
GenServer.call(table, {:buyin, player_id})
end

def player_cashout(table, player_id, chip_count) when is_integer(chip_count) do
GenServer.call(table, {:cashout, {player_id, chip_count}})
end

def handle_call({:add_player, player_name}, _from , state) do
{player, state} = state
|> Table.add_player(player_name)
BullionCore.save_player(state.id, player)
plid = player.id
{:reply, {:ok, plid}, state}
end

def handle_call({:buyin, player_id}, _from, %Table{id: table_id} = state) do
{player, state} = state |> Table.buyin(player_id)
BullionCore.save_buyin(table_id, player_id)
{:reply, :ok, state}
end

def handle_call({:cashout, {player_id, chip_count}}, _from, %Table{id: table_id} = state) do
{_player, state} = state |> Table.cashout(player_id, chip_count)
BullionCore.save_cashout(table_id, player_id, chip_count)
{:reply, :ok, state}
end

def handle_call(:view_table, _from, state) do
{:reply, {:ok, state}, state}
end

end
Loading

0 comments on commit aa6f046

Please sign in to comment.