Implement the tic-tac-toe game

This commit is contained in:
James Harton 2020-07-12 16:24:59 +12:00
parent eee2d39c4d
commit 415d9b8581
24 changed files with 1416 additions and 22 deletions

16
LICENSE Normal file
View file

@ -0,0 +1,16 @@
Copyright 2020 James Harton
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
* No Harm: The software may not be used by anyone for systems or activities that actively and knowingly endanger, harm, or otherwise threaten the physical, mental, economic, or general well-being of other individuals or groups, in violation of the United Nations Universal Declaration of Human Rights (https://www.un.org/en/universal-declaration-human-rights/).
* Services: If the Software is used to provide a service to others, the licensee shall, as a condition of use, require those others not to use the service in any way that violates the No Harm clause above.
* Enforceability: If any portion or provision of this License shall to any extent be declared illegal or unenforceable by a court of competent jurisdiction, then the remainder of this License, or the application of such portion or provision in circumstances other than those as to which it is so declared illegal or unenforceable, shall not be affected thereby, and each portion and provision of this Agreement shall be valid and enforceable to the fullest extent permitted by law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) derived from the MIT License, amended to limit the impact of the unethical use of open source software.

View file

@ -1,21 +1,75 @@
# WOPR
**TODO: Add description**
Welcome to War Operation Plan Response, a United States military supercomputer
programmed to predict possible outcomes of nuclear war.
![WOPR](https://media.giphy.com/media/jjYGVvxgQSTsc/giphy.gif)
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `wopr` to your list of dependencies in `mix.exs`:
1. Install Elixir of a version greater than 1.10.
2. Run `mix local.hex`
3. Clone [this repo](https://gitlab.com/jimsy/wopr): `git clone https://gitlab.com/jimsy/wopr`
4. Install the dependencies: `mix deps.get`
5. Compile the application: `mix compile`.
```elixir
def deps do
[
{:wopr, "~> 0.1.0"}
]
end
```
WOPR can be installed by [cloning this repo](https://gitlab.com/jimsy/wopr).
You need to have a recent version of Elixir installed.
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/wopr](https://hexdocs.pm/wopr).
## Usage
Run the `wopr` shell script in the repository directory. A heck of a lot easier
than war-dialing your way through all of Seattle.
You need to have at two instances of WOPR running either on the same machine or
local network to be able to play the game.
When you start WOPR you'll be greeted, asked for your name and then when another
player is found, the game will start. Luckily, we're not playing global
thermonuclear war.
## Technical information
The game uses [libcluster's gossip strategy][1] to locate other WOPR nodes on
the local network and [Syn][2] to distribute state across the cluster. Syn may
not be the best choice for this job, but it was very simple, requiring no setup
whatsoever.
The game is played by orchestrating three kinds of processes; `Game`, `Player` and `Shell`.
### The `Game` process
The game process holds the keys to the game board and is the only one allowed to
update the state of the board whenever a player takes a turn.
### The `Player` process
Simply used to allow players to register themselves in the cluster's registry and keep track of the player's name and score.
### The `Shell` process
I started off just writing the shell in a procedural style, but quickly realised
that it needed to behave more like a REPL - sometimes returning immediately,
sometimes waiting for something else to happen. It uses a process to keep track of the game and player state, and loops printing the appropriate message or requesting input.
## Known issues
1. I don't like that there's a process for the game. It was the fastest way to
make it work, but I think I would prefer that there was just a message
protocol for sending game states between players.
2. I don't like Syn's eventually-consistent nature for this use case - I would
prefer to use `gproc`, but couldn't immediately figure out how to enable
`gproc_dist` so gave up given the time constraints.
3. The various GenServer's know too much about their state. This should be
moved to the individual state modules - then the fact that the servers aren't
tested would be much less surprising.
4. There's a bug when you have an uneven number of players where they sometimes
try to start games with each other in a ring formation and fail. I ran out
of time to fix this, and it's a side effect of 1.
1: https://hexdocs.pm/libcluster/Cluster.Strategy.Gossip.html
2: https://hex.pm/packages/syn
## Documentation
The generated documentation for the master branch is available [here](https://jimsy.gitlab.io/wopr/)

View file

@ -7,11 +7,11 @@ config :wopr,
config: [
port: 1983,
if_addr: "0.0.0.0",
multicast_addr: "255.255.255.255",
broadcast_only: true
multicast_addr: "230.1.1.251",
multicast_ttl: 1,
secret: "WOPR"
]
]
]
config :swarm,
distribution_strategy: Swarm.Distribution.Ring
config :logger, level: :error

View file

@ -11,7 +11,10 @@ defmodule WOPR.Application do
|> Application.get_env(:topologies)
children = [
{Cluster.Supervisor, [topologies, [name: WOPR.ClusterSupervisor]]}
{Cluster.Supervisor, [topologies, [name: WOPR.ClusterSupervisor]]},
{DynamicSupervisor, strategy: :one_for_one, name: WOPR.Game.Supervisor},
{DynamicSupervisor, strategy: :one_for_one, name: WOPR.Player.Supervisor},
{DynamicSupervisor, strategy: :one_for_one, name: WOPR.Shell.Supervisor}
]
# See https://hexdocs.pm/elixir/Supervisor.html

93
lib/wopr/game.ex Normal file
View file

@ -0,0 +1,93 @@
defmodule WOPR.Game do
alias WOPR.{Game.Dist, Game.Server, Game.State, Game.Supervisor, Player}
@moduledoc """
The public API for interacting with a game.
"""
@type board :: State.board()
@type position :: State.position()
@type t :: pid
@doc """
Attempt to start a new game for a given player and opponent.
"""
@spec start(Player.t(), Player.t()) :: {:ok, t} | {:error, {:already_in_game, t}}
def start(player, opponent) when is_pid(player) and is_pid(opponent),
do: DynamicSupervisor.start_child(Supervisor, {Server, {player, opponent}})
@doc """
Wait for a new game to start between the player and the opponent.
Waits for 1 second at most.
"""
@spec await_start(Player.t(), Player.t()) :: {:ok, t} | {:error, :timeout}
def await_start(player, opponent) when is_pid(player) and is_pid(opponent),
do: await_start(player, opponent, 10)
@doc """
Is it players turn at the game?
"""
@spec turn?(t, Player.t()) :: boolean
def turn?(game, player),
do: GenServer.call(game, {:turn?, player})
@doc """
Is the game over?
"""
@spec over?(t) :: boolean
def over?(game),
do: GenServer.call(game, :over?)
@doc """
Returns the "symbol" of the player.
"""
@spec symbol(t, Player.t()) :: 0 | 1
def symbol(game, player),
do: GenServer.call(game, {:symbol, player})
@doc """
Return the contents of the game board for visualisation.
"""
@spec board(t) :: board
def board(game),
do: GenServer.call(game, :board)
@doc """
The player wants to take their turn at the game.
"""
@spec take_turn(t, Player.t(), position) ::
{:ok, :game_won, Player.t(), board}
| {:ok, :game_over, board}
| {:ok, :next_turn, Player.t(), board}
| {:error, any}
def take_turn(game, player, position),
do: GenServer.call(game, {:take_turn, player, position})
@doc """
Subscribe the calling process to turn updates about the provided game.
"""
@spec subscribe(t) :: :ok
def subscribe(game),
do: Dist.subscribe(game)
@doc """
Return the winner of the game (if any).
"""
@spec winner(t) :: Player.t() | nil
def winner(game),
do: GenServer.call(game, :winner)
defp await_start(_player, _opponent, 0),
do: {:error, :timeout}
# Try up to 10 times to find the game process, then give up.
defp await_start(player, opponent, i) do
Process.sleep(100)
case Dist.find(player, opponent) do
{:ok, pid} -> {:ok, pid}
{:error, :not_found} -> await_start(player, opponent, i - 1)
end
end
end

55
lib/wopr/game/dist.ex Normal file
View file

@ -0,0 +1,55 @@
defmodule WOPR.Game.Dist do
alias WOPR.{Game, Player}
@moduledoc """
This module handles the distrubuted registry of players and pubsub events.
"""
@doc """
Attempt to register a new game between the two specified players.
Will fail if there's already a game present for those players.
"""
@spec register(Player.t(), Player.t()) :: :ok | {:error, any}
def register(player_a, player_b),
do: :syn.register({__MODULE__, sort_players(player_a, player_b)}, self())
@doc """
Attempt to find a game between the two specified players.
"""
@spec find(Player.t(), Player.t()) :: {:ok, pid} | {:error, :not_found}
def find(player_a, player_b) do
case :syn.whereis({__MODULE__, sort_players(player_a, player_b)}) do
:undefined -> {:error, :not_found}
pid when is_pid(pid) -> {:ok, pid}
end
end
@doc """
Subscribe to messages about the outcome of a game.
"""
@spec subscribe(Game.t()) :: :ok
def subscribe(game) when is_pid(game),
do: :syn.join({__MODULE__, game}, self())
@doc """
Publish a message to subscribers telling them whose turn it is.
"""
@spec turn_of(Game.t(), Player.t()) :: :ok
def turn_of(game, player) when is_pid(game) and is_pid(player),
do: publish(game, {:turn_of, player})
defp publish(game, message) when is_pid(game) do
:syn.publish({__MODULE__, game}, message)
:ok
end
# Due to the fact that pids are numerically sortable, we're able to generate a
# unique key for an arbitrary pair of players.
defp sort_players(player_a, player_b)
when is_pid(player_a) and is_pid(player_b) and player_a < player_b,
do: {player_a, player_b}
defp sort_players(player_a, player_b) when is_pid(player_a) and is_pid(player_b),
do: {player_b, player_a}
end

95
lib/wopr/game/server.ex Normal file
View file

@ -0,0 +1,95 @@
defmodule WOPR.Game.Server do
use GenServer, restart: :temporary
alias WOPR.{Game.Dist, Game.Server, Game.State, Player}
@moduledoc """
The main game GenServer.
Uses the game `State` and `Dist` to manage the game and let players know
what's happening.
"""
@doc """
Starts the game for a pair of players.
"""
@spec start_link({pid, pid}) :: {:ok, pid} | {:error, any}
def start_link({player, opponent}),
do: GenServer.start_link(Server, {player, opponent})
@impl true
def init({player, opponent}) do
# If the player or the opponent are already in a game we can't start.
with :ok <- Player.new_game(player, self()),
:ok <- Player.new_game(opponent, self()) do
# We want to know if either of the players goes away.
Process.monitor(player)
Process.monitor(opponent)
# Pick a random player to go first.
random_player =
[player, opponent]
|> Enum.random()
# Register this game with the cluster.
Dist.register(player, opponent)
# Let the first player know it's their turn.
Dist.turn_of(self(), random_player)
{:ok, State.init(player: player, opponent: opponent, whose_turn: random_player)}
else
{:error, reason} -> {:stop, {:error, reason}}
end
end
@impl true
# Is it the player's turn?
def handle_call({:turn?, player}, _from, %State{whose_turn: player} = state),
do: {:reply, true, state}
def handle_call({:turn?, _player}, _from, state),
do: {:reply, false, state}
# Get the contents of the game board.
def handle_call(:board, _from, %State{board: board} = state),
do: {:reply, board, state}
# Return the "symbol" of the player
def handle_call({:symbol, player}, _from, %State{player: player} = state),
do: {:reply, 0, state}
def handle_call({:symbol, player}, _from, %State{opponent: player} = state),
do: {:reply, 1, state}
# Allow the player to take their turn, if possible.
def handle_call({:take_turn, player, position}, _from, state) do
case State.take_turn(state, player, position) do
{:ok, state} ->
winner = State.winner(state)
cond do
is_pid(winner) ->
{:stop, {:game_won, winner, state.board}, {:ok, :game_won, winner, state.board},
state}
State.over?(state) ->
{:stop, {:game_over, state.board}, {:ok, :game_over, state.board}, state}
true ->
Dist.turn_of(self(), state.whose_turn)
{:reply, {:ok, :next_turn, state.whose_turn, state.board}, state}
end
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
# Is the game over?
def handle_call(:over?, _from, state),
do: {:reply, State.over?(state), state}
# Is there a winner?
def handle_call(:winner, _from, state),
do: {:reply, State.winner(state), state}
end

161
lib/wopr/game/state.ex Normal file
View file

@ -0,0 +1,161 @@
defmodule WOPR.Game.State do
defstruct board: {nil, nil, nil, nil, nil, nil, nil, nil, nil},
player: nil,
opponent: nil,
whose_turn: nil
alias WOPR.Game.State
@moduledoc """
Handles the state of the game process.
This nicely encalsulates the game logic away from the GenServer logic to make
it easier to test and understand.
"""
# The state of an individual game cell.
@type cell_state :: nil | 0 | 1
# A position on the board.
@type position :: 0..8
# A player of the game. Usually a pid.
@type player :: any
# The state of the game.
@type t :: %State{
board:
{cell_state, cell_state, cell_state, cell_state, cell_state, cell_state, cell_state,
cell_state, cell_state},
player: player,
opponent: player,
whose_turn: player
}
@doc """
Initialise a new game state, with the provided options.
## Example
iex> State.init(player: :a, opponent: :b)
%State{player: :a, opponent: :b}
"""
@spec init(Keyword.t()) :: t
def init(options), do: struct(State, options)
@doc """
Try and apply a players turn to the board.
Returns an updated state if successful, otherwise an error (eg if that
position has already been played or it's not this player's turn).
## Example
iex> State.init(player: :a, opponent: :b, whose_turn: :a)
...> |> State.take_turn(:a, 0)
{:ok, %State{board: {0, nil, nil, nil, nil, nil, nil, nil, nil}, opponent: :b, player: :a, whose_turn: :b}}
"""
@spec take_turn(t, player, position) :: {:ok, t} | {:error, any}
def take_turn(
%State{player: player, whose_turn: player, opponent: opponent} = state,
player,
position
) do
with {:ok, state} <- update_board(state, 0, position),
do: {:ok, %State{state | whose_turn: opponent}}
end
def take_turn(
%State{player: player, whose_turn: opponent, opponent: opponent} = state,
opponent,
position
) do
with {:ok, state} <- update_board(state, 1, position),
do: {:ok, %State{state | whose_turn: player}}
end
def take_turn(%State{whose_turn: next_turn_player}, player, _position)
when next_turn_player !== player,
do: {:error, :not_your_turn}
@doc """
Is the game over?
The game is over if all the positions on the board are taken, or someone has
won.
## Examples
iex> State.init(board: {0, 0, 0, nil, nil, nil, nil, nil, nil})
...> |> State.over?()
true
iex> State.init(board: {0, 1, 0, 1, 0, 1, 0, 1, 0})
...> |> State.over?()
true
iex> State.init([])
...> |> State.over?()
false
"""
@spec over?(t) :: boolean
def over?(%State{board: board}) do
has_winner =
board
|> find_winner()
|> is_number()
board_full =
board
|> Tuple.to_list()
|> Enum.all?(&is_number(&1))
has_winner || board_full
end
@doc """
Return the player who has won the game (if any).
## Examples
iex> State.init(player: :a, opponent: :b, board: {0, 0, 0, nil, nil, nil, nil, nil, nil})
...> |> State.winner()
:a
iex> State.init(player: :a, opponent: :b)
...> |> State.winner()
nil
"""
@spec winner(t) :: player | nil
def winner(%State{board: board, player: player, opponent: opponent}) do
case find_winner(board) do
nil -> nil
0 -> player
1 -> opponent
end
end
# Only allow a position to be played on the board if it hasn't already been
# played.
defp update_board(%State{board: board} = state, symbol, position)
when is_nil(elem(board, position)),
do: {:ok, %State{state | board: put_elem(board, position, symbol)}}
defp update_board(_state, _symbol, _position),
do: {:error, :position_already_played}
# I was going to write tests for this, but they wound up just mimicing the
# pattern being matched, so it wasn't worth it.
defp find_winner({p, p, p, _, _, _, _, _, _}) when is_number(p), do: p
defp find_winner({_, _, _, p, p, p, _, _, _}) when is_number(p), do: p
defp find_winner({_, _, _, _, _, _, p, p, p}) when is_number(p), do: p
defp find_winner({p, _, _, p, _, _, p, _, _}) when is_number(p), do: p
defp find_winner({_, p, _, _, p, _, _, p, _}) when is_number(p), do: p
defp find_winner({_, _, p, _, _, p, _, _, p}) when is_number(p), do: p
defp find_winner({p, _, _, _, p, _, _, _, p}) when is_number(p), do: p
defp find_winner({_, _, p, _, p, _, p, _, _}) when is_number(p), do: p
defp find_winner(_board), do: nil
end

58
lib/wopr/player.ex Normal file
View file

@ -0,0 +1,58 @@
defmodule WOPR.Player do
alias WOPR.Player.{Dist, Server, Supervisor}
@moduledoc """
The public API for working with players.
"""
@type t :: pid
@doc """
Register a new player with the provided name.
"""
@spec register(binary) :: {:ok, t} | {:error, any}
def register(player_name),
do: DynamicSupervisor.start_child(Supervisor, {Server, [player_name]})
@doc """
Remove the user from the player registry.
"""
@spec unregister(t) :: :ok
def unregister(player),
do: GenServer.stop(player, :normal)
@doc """
Return the player's current score.
"""
@spec score(t) :: {:ok, non_neg_integer} | {:error, any}
def score(player),
do: GenServer.call(player, :score)
@doc """
Return the name of the player.
"""
@spec name(t) :: {:ok, binary} | {:error, any}
def name(player),
do: GenServer.call(player, :name)
@doc """
Is the user currently playing a game?
"""
@spec playing?(t) :: boolean
def playing?(player),
do: GenServer.call(player, :playing?)
@doc """
Instruct the player to join a new game.
"""
@spec new_game(t, Game.t()) :: :ok | {:error, any}
def new_game(player, game) when is_pid(game),
do: GenServer.call(player, {:new_game, game})
@doc """
Return a list of all known players.
"""
@spec list_players :: [t]
def list_players,
do: Dist.list()
end

42
lib/wopr/player/dist.ex Normal file
View file

@ -0,0 +1,42 @@
defmodule WOPR.Player.Dist do
alias WOPR.Player
@moduledoc """
This module handles publishing and subscribing players to the distributed
registry.
"""
@doc """
Called by players when they register themselves.
"""
@spec join :: :ok
def join, do: :syn.join(__MODULE__, self(), :player)
@doc """
Called by other processes interested in player announcements.
"""
@spec subscribe :: :ok
def subscribe, do: :syn.join(__MODULE__, self(), :subscriber)
@doc """
Publish the pid of the calling process as a registered player.
"""
@spec announce :: :ok
def announce, do: publish({:announce, self()})
@doc """
Return a list of the pids of all registered players.
"""
@spec list :: [Player.t()]
def list do
__MODULE__
|> :syn.get_members(:with_meta)
|> Enum.filter(&(elem(&1, 1) == :player))
|> Enum.map(&elem(&1, 0))
end
defp publish(message) do
{:ok, _} = :syn.publish(__MODULE__, message)
:ok
end
end

62
lib/wopr/player/server.ex Normal file
View file

@ -0,0 +1,62 @@
defmodule WOPR.Player.Server do
alias WOPR.{Player.Dist, Player.Server, Player.State}
use GenServer
@moduledoc """
A very simple GenServer which contains the player's state and tracks their
score.
"""
@spec start_link([binary]) :: {:ok, pid} | {:error, any}
def start_link([player_name]), do: GenServer.start_link(Server, [player_name])
@impl true
def init([player_name]) do
state = State.init(player_name)
:ok = Dist.join()
:ok = Dist.announce()
{:ok, state}
end
@impl true
# ignore player announcements.
def handle_info({:announce, _}, state), do: {:noreply, state}
# if we won a game, then increment our score.
def handle_info(
{:DOWN, _ref, :process, pid, {:game_won, winner, _board}},
%State{game: pid, score: score} = state
)
when winner == self(),
do: {:noreply, %State{state | game: nil, score: score + 1}}
# if the game has stoppoed for any other reason then stop paying attention.
def handle_info({:DOWN, _ref, :process, pid, _reason}, %State{game: pid} = state),
do: {:noreply, %State{state | game: nil}}
@impl true
# Are we currently playing a game?
def handle_call(:playing?, _from, %State{game: nil} = state),
do: {:reply, false, state}
def handle_call(:playing?, _from, state),
do: {:reply, true, state}
# What's our current score?
def handle_call(:score, _from, %State{score: score} = state),
do: {:reply, {:ok, score}, state}
# What's our name?
def handle_call(:name, _from, %State{name: name} = state),
do: {:reply, {:ok, name}, state}
# We're playing a new game. Yay!
def handle_call({:new_game, game}, _from, %State{game: nil} = state) do
Process.monitor(game)
{:reply, :ok, %{state | game: game}}
end
# We can't be added to a game if we're already playing one.
def handle_call({:new_game, _game}, _from, state),
do: {:reply, {:error, {:already_in_game, self()}}, state}
end

20
lib/wopr/player/state.ex Normal file
View file

@ -0,0 +1,20 @@
defmodule WOPR.Player.State do
defstruct name: nil, score: 0, game: nil
alias WOPR.{Game, Player.State}
@moduledoc """
Holds the player state, ie their name, score and current game.
"""
@type t :: %State{
name: binary,
score: non_neg_integer,
game: nil | Game.t()
}
@doc """
Initialises a new player state with the player's name
"""
@spec init(binary) :: t
def init(name) when is_binary(name), do: %State{name: name}
end

210
lib/wopr/shell.ex Normal file
View file

@ -0,0 +1,210 @@
defmodule WOPR.Shell do
alias WOPR.{Game, Player, Shell, Shell.API, Shell.Server, Shell.Supervisor}
# Comment this line if you'd like things to go faster:
alias WOPR.Shell.IO
@moduledoc """
The main player shell.
"""
@doc """
The main entrypoint of the shell.
"""
@spec main :: no_return
def main do
disable_logs()
{:ok, pid} = DynamicSupervisor.start_child(Supervisor, Server)
loop(pid)
end
@doc """
The main shell loop.
Retrieves the current state of the shell server and uses it to delegate to a
function which interacts with the user.
"""
@spec loop(Server.t()) :: no_return
def loop(pid) do
case API.state(pid) do
state when is_atom(state) ->
apply(Shell, state, [pid])
{state, args} when is_atom(state) and is_list(args) ->
apply(Shell, state, [pid | args])
end
loop(pid)
end
@doc """
Prints out a greeting message and prompts for the player's name.
"""
@spec greet(Server.t()) :: :ok
def greet(pid) do
IO.puts("GREETINGS PROFESSOR FALKEN. SHALL WE PLAY A GAME?\n")
{:ok, name} = prompt_for_name()
API.register_player(pid, name)
end
@doc """
Uses the shell server to block until there is an opponent to play against.
"""
@spec wait_for_opponent(Server.t()) :: :ok
def wait_for_opponent(pid) do
IO.write("WAITING FOR OTHER PLAYERS...")
{:ok, opponent} = API.wait_for_opponent(pid)
{:ok, name} = Player.name(opponent)
{:ok, score} = Player.score(opponent)
IO.puts("FOUND OTHER PLAYER #{name} WITH SCORE #{score}.\n")
end
@doc """
Try to start a new game with the opponent and print the appropriate message.
"""
@spec start_game(Server.t()) :: :ok
def start_game(pid) do
case API.start_game(pid) do
{:ok, symbol} ->
IO.puts("STARTED NEW GAME. YOU ARE PLAYING #{symbol}.\n")
{:error, {:already_in_game, player}} ->
{:ok, name} = Player.name(player)
IO.warn("ERROR: #{name} IS ALREADY PLAYING A GAME. TRYING AGAIN.")
{:error, :timeout} ->
IO.warn("ERROR: UNABLE TO START. TRYING AGAIN")
end
end
@doc """
Prompt the user for their move, and take it.
"""
@spec take_turn(Server.t(), Game.board()) :: :ok
def take_turn(pid, board) do
print_game_board(board)
{:ok, position} = prompt_for_coordinates()
if API.take_turn(pid, position) == {:error, :position_already_played} do
IO.warn("ERROR: THESE COORDINATES HAVE ALREADY BEEN PLAYED")
end
end
@doc """
Use the shell server to block until it's our turn.
"""
@spec await_turn(Server.t(), Player.t()) :: :ok
def await_turn(pid, opponent) do
{:ok, name} = Player.name(opponent)
IO.write("WAITING FOR #{name} TO TAKE THEIR TURN...")
API.await_turn(pid)
IO.puts("\n")
end
@doc """
Tell the user that they have won the game.
"""
@spec game_won(Server.t(), Game.board()) :: :ok
def game_won(pid, board) do
print_game_board(board)
IO.puts("CONGRATULATIONS! YOU WON!\n\n")
API.restart(pid)
end
@doc """
Tell the user that they have lost the game.
"""
@spec game_lost(Server.t(), Game.board()) :: :ok
def game_lost(pid, board) do
print_game_board(board)
IO.puts("YOU LOST THE GAME.\n\n")
API.restart(pid)
end
@doc """
Tell the user that the game is over and no one has won.
"""
@spec game_over(Server.t(), Game.board()) :: :ok
def game_over(pid, board) do
print_game_board(board)
IO.puts("A STRANGE GAME.\nTHE ONLY WINNING MOVE IS\nNOT TO PLAY.\n\n")
API.restart(pid)
end
@doc """
Tell the user about a number of possible error states and restart the shell.
"""
@spec error(Server.t(), any) :: :ok
def error(pid, :opponent_gone) do
IO.warn("ERROR: THE OPPONENT HAS LEFT THE GAME. STARTING OVER.")
API.restart(pid)
end
def error(pid, _) do
IO.warn("ERROR: THE GAME HAS CRASHED. STARTING OVER.")
API.restart(pid)
end
defp print_game_board(board) do
IO.puts("\nGAME BOARD:")
[a1, a2, a3, b1, b2, b3, c1, c2, c3] =
board
|> Tuple.to_list()
|> Enum.map(&to_symbol(&1))
IO.puts(" | 1 | 2 | 3")
IO.puts("---+---+---+---")
IO.puts(" A | #{a1} | #{a2} | #{a3}")
IO.puts(" B | #{b1} | #{b2} | #{b3}")
IO.puts(" C | #{c1} | #{c2} | #{c3}\n")
end
defp to_symbol(nil), do: " "
defp to_symbol(0), do: "O"
defp to_symbol(1), do: "X"
defp prompt_for_coordinates do
coordinates =
"PLEASE ENTER THE COORDINATES FOR YOUR TURN: "
|> IO.prompt()
|> String.upcase()
if Regex.match?(~r/^[A-C][1-3]$/, coordinates) do
position =
coordinates
|> position_from_coordinates()
{:ok, position}
else
IO.warn("ERROR: YOU HAVE ENTERED INVALID COORDINATES")
IO.warn("COORDINATES MUST BE IN THE FORMAT OF A LETTER (A-C) FOLLOWED BY A NUMBER (1-3).\n")
prompt_for_coordinates()
end
end
defp position_from_coordinates("A1"), do: 0
defp position_from_coordinates("A2"), do: 1
defp position_from_coordinates("A3"), do: 2
defp position_from_coordinates("B1"), do: 3
defp position_from_coordinates("B2"), do: 4
defp position_from_coordinates("B3"), do: 5
defp position_from_coordinates("C1"), do: 6
defp position_from_coordinates("C2"), do: 7
defp position_from_coordinates("C3"), do: 8
defp prompt_for_name do
case IO.prompt("WHAT IS YOUR FIRST NAME? ") do
"" ->
IO.warn("PLEASE ENTER A VALID FIRST NAME.")
prompt_for_name()
name ->
{:ok, "DR. #{String.upcase(name)} FALKEN"}
end
end
defp disable_logs, do: Logger.remove_backend(:console)
end

58
lib/wopr/shell/api.ex Normal file
View file

@ -0,0 +1,58 @@
defmodule WOPR.Shell.API do
alias WOPR.Shell.{Server, State}
@moduledoc """
The API used by `WOPR.Shell` to interact with `WOPR.Shell.Server`.
Mainly here to make `Shell` easier to read.
"""
@doc """
Ask the server for the current shell state.
"""
@spec state(Server.t()) :: State.state()
def state(pid),
do: GenServer.call(pid, :get_state)
@doc """
Register a new player by name.
"""
@spec register_player(Server.t(), binary) :: :ok
def register_player(pid, name),
do: GenServer.call(pid, {:register_player, name})
@doc """
Block until there's an opponent for us to play or something terrible happens.
"""
@spec wait_for_opponent(Server.t()) :: :ok
def wait_for_opponent(pid),
do: GenServer.call(pid, :wait_for_opponent, :infinity)
@doc """
We're ready to start a game. Also, what's our symbol?
"""
@spec start_game(Server.t()) :: {:ok, binary} | {:error, any}
def start_game(pid),
do: GenServer.call(pid, :start_game)
@doc """
Take our turn by playing in the provided position.
"""
@spec take_turn(Server.t(), Game.position()) :: :ok | {:error, any}
def take_turn(pid, position),
do: GenServer.call(pid, {:take_turn, position})
@doc """
Block until it's our turn to move again (or something bad happens).
"""
@spec await_turn(Server.t()) :: :ok | {:error, any}
def await_turn(pid),
do: GenServer.call(pid, :await_turn, :infinity)
@doc """
Reset the shell state and start over.
"""
@spec restart(Server.t()) :: :ok
def restart(pid),
do: GenServer.cast(pid, :restart)
end

46
lib/wopr/shell/io.ex Normal file
View file

@ -0,0 +1,46 @@
defmodule WOPR.Shell.IO do
@moduledoc """
Handle reading and writing to stdio/stderr for the WOPR Shell.
Here we deliberately slow down the printing of characters to stdout by placing
a 27ms gap between them. This simulates being connected to the game via a 300
baud modem.
"""
@doc """
Prompt the user for input on `STDIN`.
Prints `prompt` and then returns the stripped contents of the user input.
"""
@spec prompt(binary) :: binary
def prompt(prompt) do
write(prompt)
IO.read(:line)
|> String.trim()
end
@doc """
(Slowly) prints `message` to `STDOUT`, followed by a new line.
"""
@spec puts(binary) :: :ok
def puts(message), do: write("#{message}\n")
@doc """
(Slowly) prints `message` to `STDOUT`.
"""
@spec write(binary) :: :ok
def write(message, port \\ :stdio) do
message
|> String.split("")
|> Stream.map(&fn -> IO.write(port, &1) end)
|> Stream.intersperse(fn -> Process.sleep(27) end)
|> Enum.each(& &1.())
end
@doc """
(Slowly) prints `message` to `STDERR`.
"""
@spec warn(binary) :: :ok
def warn(message), do: write("#{message}\n", :stderr)
end

244
lib/wopr/shell/server.ex Normal file
View file

@ -0,0 +1,244 @@
defmodule WOPR.Shell.Server do
alias WOPR.{Game, Player, Shell.Server, Shell.State}
use GenServer
@moduledoc """
The shell server orchestrates the shell state by reacting to messages from the
shell, the player and the game processes.
"""
# A shell server is just a pid.
@type t :: pid
@doc """
Start a new shell server.
"""
@spec start_link(any) :: {:ok, pid} | {:error, any}
def start_link(_),
do: GenServer.start_link(Server, [])
@impl true
def init([]),
do: {:ok, State.init()}
@impl true
# What's the current shell state. Called every time the shell loops.
def handle_call(:get_state, _from, %State{state: cli_state} = state),
do: {:reply, cli_state, state}
# Get me the opponent please.
def handle_call(:get_opponent, _from, %State{opponent: nil} = state),
do: {:reply, {:error, :no_opponent}, state}
def handle_call(:get_opponent, _from, %State{opponent: opponent} = state),
do: {:reply, {:ok, opponent}, state}
# Wait for an opponent to be present. Short circuits if there is already one waiting.
def handle_call(
:wait_for_opponent,
from,
%State{player: player, state: :wait_for_opponent} = state
) do
players =
Player.Dist.list()
|> Enum.reject(&(&1 == player))
|> Enum.reject(&Player.playing?(&1))
case players do
[] ->
Player.Dist.subscribe()
{:noreply, %State{state | state: {:waiting_for_opponent, [from]}}}
opponents ->
opponent =
opponents
|> Enum.random()
Process.monitor(opponent)
{:reply, {:ok, opponent}, %State{state | state: :start_game, opponent: opponent}}
end
end
# Start a new game.
def handle_call(
:start_game,
_from,
%State{state: :start_game, player: player, opponent: opponent} = state
) do
# in order to avoid a race condition where both players try and start a game
# faster than the network can synchronise the registry one player will start
# the game and the other will wait for it to start.
join_method =
if player > opponent,
do: :start,
else: :await_start
case apply(Game, join_method, [player, opponent]) do
{:ok, pid} ->
# Monitor the game so that we know if it terminates.
Process.monitor(pid)
symbol =
case Game.symbol(pid, player) do
0 -> "NOUGHTS"
1 -> "CROSSES"
end
# Subscribe to turn notifications.
Game.subscribe(pid)
{:reply, {:ok, symbol}, next_turn_state(%State{state | game: pid})}
{:error, reason} ->
{:reply, {:error, reason}, %State{state | state: :wait_for_opponent}}
end
end
# Take my turn at the game.
def handle_call(
{:take_turn, position},
_from,
%State{state: {:take_turn, _}, game: game, player: player, opponent: opponent} = state
) do
case Game.take_turn(game, player, position) do
{:ok, :game_won, ^player, board} ->
{:reply, :ok, %State{state | state: {:game_won, [board]}}}
{:ok, :game_won, ^opponent, board} ->
{:reply, :ok, %State{state | state: {:game_lost, [board]}}}
{:ok, :game_over, board} ->
{:reply, :ok, %State{state | state: {:game_over, [board]}}}
{:ok, :next_turn, ^player, board} ->
{:reply, :ok, %State{state | state: {:take_turn, [board]}}}
{:ok, :next_turn, ^opponent, _board} ->
{:reply, :ok, %State{state | state: {:await_turn, [opponent]}}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
# Block until I can take my turn, unless it's already my turn, of course.
def handle_call(
:await_turn,
from,
%State{state: {:await_turn, _}, game: game, player: player} = state
) do
if Game.turn?(game, player) do
board = Game.board(game)
{:reply, :ok, %State{state | state: {:take_turn, [board]}}}
else
{:noreply, %State{state | state: {:awaiting_turn, [from]}}}
end
end
# Register a new player.
def handle_call({:register_player, name}, _from, state) do
{:ok, pid} = Player.register(name)
Process.monitor(pid)
{:reply, :ok, %State{state | player: pid, state: :wait_for_opponent}}
end
@impl true
# Reset the shell state back to the beginning.
def handle_cast(:restart, %State{game: game} = state) do
if is_pid(game), do: Process.exit(game, :normal)
{:noreply, %State{state | state: :wait_for_opponent, game: nil, opponent: nil}}
end
@impl true
# If we're currently waiting for an opponent and a new one is announced then
# we should unblock the shell and move on.
def handle_info({:announce, opponent}, %State{state: {:waiting_for_opponent, [from]}} = state) do
Process.monitor(opponent)
GenServer.reply(from, {:ok, opponent})
{:noreply, %State{state | state: :start_game, opponent: opponent}}
end
# Otherwise we don't care about player announcements.
def handle_info({:announce, _opponent}, state),
do: {:noreply, state}
# If we are waiting for our turn and we receive a message telling us that it's
# our turn then unblock the shell and continue.
def handle_info(
{:turn_of, player},
%State{player: player, state: {:awaiting_turn, [from]}} = state
) do
GenServer.reply(from, :ok)
{:noreply, next_turn_state(state)}
end
# Ignore the other player's turn.
def handle_info({:turn_of, _}, state), do: {:noreply, state}
# The game is over and we won. Yay!
def handle_info(
{:DOWN, _, _, game, {:game_won, player, board}},
%State{game: game, player: player} = state
) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:game_won, [board]}}}
end
# The game is over and the opponent won. Boohoo!
def handle_info(
{:DOWN, _, _, game, {:game_won, opponent, board}},
%State{game: game, opponent: opponent} = state
) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:game_lost, [board]}}}
end
# The game is over and no one won.
def handle_info({:DOWN, _, _, game, {:game_over, board}}, %State{game: game} = state) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:game_over, [board]}}}
end
# The game has crashed, or dissappeared from the cluster.
def handle_info({:DOWN, _, _, game, _}, %State{game: game} = state) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:error, [:game_gone]}}}
end
# Our own player process has crashed. This is bad, but recoverable.
def handle_info({:DOWN, _, _, player, _}, %State{player: player} = state) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:error, [:player_gone]}}}
end
# The other player has left the cluster.
def handle_info({:DOWN, _, _, opponent, _}, %State{opponent: opponent} = state) do
send_reply_if_appropriate(state)
{:noreply, %State{state | state: {:error, [:opponent_gone]}}}
end
# If it's our turn then tell the shell to take it, otherwise tell it to wait.
defp next_turn_state(%State{game: game, player: player, opponent: opponent} = state) do
if Game.turn?(game, player) do
board = Game.board(game)
%State{state | state: {:take_turn, [board]}}
else
%State{state | state: {:await_turn, [opponent]}}
end
end
# It could be that a shell is waiting for us to reply when something bad happens,
# so we just reply to ensure that it passed back into the next loop turn.
defp send_reply_if_appropriate(%State{state: {:awaiting_turn, [from]}}),
do: GenServer.reply(from, :ok)
defp send_reply_if_appropriate(%State{state: {:waiting_for_opponent, [from]}}),
do: GenServer.reply(from, :ok)
defp send_reply_if_appropriate(_state),
do: :ok
end

35
lib/wopr/shell/state.ex Normal file
View file

@ -0,0 +1,35 @@
defmodule WOPR.Shell.State do
defstruct state: nil, player: nil, game: nil, opponent: nil
alias WOPR.{Game, Player, Shell.State}
@moduledoc """
Stores the state of and metadata about the a user's shell session.
"""
# A valid shell state.
@type state ::
:greet
| :wait_for_opponent
| {:waiting_for_opponent, [GenServer.from()]}
| :start_game
| {:game_won, [Game.board()]}
| {:game_lost, [Game.board()]}
| {:game_over, [Game.board()]}
| {:take_turn, [Game.board()]}
| {:await_turn, [Player.t()]}
| {:awaiting_turn, [GenServer.from()]}
| {:error, [:game_gone | :player_gone | :opponent_gone]}
@type t :: %State{
state: state,
player: Player.t() | nil,
opponent: Player.t() | nil,
game: Game.t() | nil
}
@doc """
Initialise a new shell state with the default values.
"""
@spec init :: t
def init, do: %State{state: :greet}
end

View file

@ -6,6 +6,7 @@ defmodule WOPR.MixProject do
app: :wopr,
version: "0.1.0",
elixir: "~> 1.10",
description: "Let's play global thermonuclear war",
start_permanent: Mix.env() == :prod,
package: package(),
deps: deps()
@ -37,7 +38,8 @@ defmodule WOPR.MixProject do
{:earmark, ">= 0.0.0", only: [:dev, :test]},
{:ex_doc, ">= 0.0.0", only: [:dev, :test]},
{:libcluster, "~> 3.2"},
{:swarm, "~> 3.4"}
{:mimic, "~> 1.3", only: :test},
{:syn, "~> 2.1"}
]
end
end

View file

@ -4,12 +4,11 @@
"earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"},
"earmark_parser": {:hex, :earmark_parser, "1.4.9", "819bda2049e6ee1365424e4ced1ba65806eacf0d2867415f19f3f80047f8037b", [:mix], [], "hexpm", "8bf54fddabf2d7e137a0c22660e71b49d5a0a82d1fb05b5af62f2761cd6485c4"},
"ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"},
"gen_state_machine": {:hex, :gen_state_machine, "2.1.0", "a38b0e53fad812d29ec149f0d354da5d1bc0d7222c3711f3a0bd5aa608b42992", [:mix], [], "hexpm", "ae367038808db25cee2f2c4b8d0531522ea587c4995eb6f96ee73410a60fa06b"},
"jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
"libcluster": {:hex, :libcluster, "3.2.1", "b2cd5b447cde25d5897749bee6f7aaeb6c96ac379481024e9b6ba495dabeb97d", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "89f225612d135edce9def56f43bf18d575d88ac4680e3f6161283f2e55cadca4"},
"libring": {:hex, :libring, "1.5.0", "44313eb6862f5c9168594a061e9d5f556a9819da7c6444706a9e2da533396d70", [:mix], [], "hexpm", "04e843d4fdcff49a62d8e03778d17c6cb2a03fe2d14020d3825a1761b55bd6cc"},
"makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
"mimic": {:hex, :mimic, "1.3.0", "2fdca63b897fe4367542651ca83759a49536cefb7c7f9eea8146695112cb3ee1", [:mix], [], "hexpm", "3c9aa460b7992e381987d106433a29ac3c36e50be7c47f6e0ea46b3a3d7f559b"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"},
"syn": {:hex, :syn, "2.1.1", "51bb544ce3afcd84cfed4074b22d56143420e64a28a4d59af3cf0330a8635901", [:rebar3], [], "hexpm", "40c43ffabf70d6165f862748bb28f1adaf6324fd38ceee24733b0dc1067ab358"},
}

View file

@ -1 +1,2 @@
Mimic.copy(:syn)
ExUnit.start()

View file

@ -0,0 +1,40 @@
defmodule WOPR.Game.DistTest do
alias WOPR.Game.Dist
use ExUnit.Case, async: true
use Mimic
describe "register/2" do
test "it registers the process with the `:syn` registry" do
{:ok, pida} = Agent.start_link(fn -> :a end)
{:ok, pidb} = Agent.start_link(fn -> :b end)
:syn
|> expect(:register, fn {Dist, {^pida, ^pidb}}, pid -> pid == self() end)
assert Dist.register(pidb, pida)
end
end
describe "subscribe/1" do
test "it subscribes the calling process to the game process" do
{:ok, game} = Agent.start_link(fn -> :fake_game end)
:syn
|> expect(:join, fn {Dist, ^game}, pid -> pid == self() end)
assert Dist.subscribe(game)
end
end
describe "turn_of/2" do
test "it notifies subscribers of a new game turn" do
{:ok, game} = Agent.start_link(fn -> :fake_game end)
{:ok, player} = Agent.start_link(fn -> :fake_player end)
:syn
|> expect(:publish, fn {Dist, ^game}, {:turn_of, ^player} -> true end)
assert Dist.turn_of(game, player)
end
end
end

View file

@ -0,0 +1,51 @@
defmodule WOPR.Game.StateTest do
use ExUnit.Case, async: true
alias WOPR.Game.State
doctest WOPR.Game.State
describe "take_turn/3" do
test "it succeeds when it is the players turn and the cell is empty" do
state = State.init(player: :a, opponent: :b, whose_turn: :a)
assert {:ok, %State{board: {0, nil, nil, nil, nil, nil, nil, nil, nil}}} =
State.take_turn(state, :a, 0)
end
test "it fails when it is not the player's turn" do
state = State.init(player: :a, opponent: :b, whose_turn: :b)
assert {:error, :not_your_turn} = State.take_turn(state, :a, 0)
end
end
describe "over?/1" do
test "when all the cells are played, it is true" do
state = State.init(board: {0, 0, 0, 0, 0, 0, 0, 0, 0})
assert State.over?(state)
end
test "when the game is won, it is true" do
state = State.init(board: {0, 0, 0, nil, nil, nil, nil, nil, nil})
assert State.over?(state)
end
test "otherwise, it's false" do
refute State.over?(State.init([]))
end
end
describe "winner/1" do
test "when the game is not won, returns nil" do
refute State.winner(State.init([]))
end
test "when the game is won by the player, it returns the player" do
state = State.init(player: :a, opponent: :b, board: {0, 0, 0, nil, nil, nil, nil, nil, nil})
assert State.winner(state) == :a
end
test "when the game is won by the opponent, it returns the player" do
state = State.init(player: :a, opponent: :b, board: {1, 1, 1, nil, nil, nil, nil, nil, nil})
assert State.winner(state) == :b
end
end
end

View file

@ -0,0 +1,43 @@
defmodule WOPR.Player.DistTest do
alias WOPR.Player.Dist
use ExUnit.Case, async: true
use Mimic
describe "join/0" do
test "it registers the calling process as a player" do
:syn
|> expect(:join, fn Dist, pid, :player -> pid == self() end)
assert Dist.join()
end
end
describe "subscribe/0" do
test "it registers the calling process as a subscriber" do
:syn
|> expect(:join, fn Dist, pid, :subscriber -> pid == self() end)
assert Dist.subscribe()
end
end
describe "announce/0" do
test "it announces the current process as a player to all subscribers" do
:syn
|> expect(:publish, fn Dist, {:announce, pid} -> if pid == self(), do: {:ok, :wat} end)
assert Dist.announce()
end
end
describe "list/0" do
test "it lists all the players" do
{:ok, player} = Agent.start_link(fn -> Dist.join() end)
{:ok, subscriber} = Agent.start_link(fn -> Dist.subscribe() end)
players = Dist.list()
assert player in players
refute subscriber in players
end
end
end

6
wopr Executable file
View file

@ -0,0 +1,6 @@
#!/bin/sh
# If you don't have openssl installed you probably have bigger problems.
RANDOM_NODE_NAME=`openssl rand -hex 12`
exec elixir --sname $RANDOM_NODE_NAME -S mix run -e WOPR.Shell.main