diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0589941 --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/README.md b/README.md index be827fe..1f0ba9a 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/config/config.exs b/config/config.exs index 599ad02..9bb4169 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/lib/wopr/application.ex b/lib/wopr/application.ex index d8e7720..95a2591 100644 --- a/lib/wopr/application.ex +++ b/lib/wopr/application.ex @@ -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 diff --git a/lib/wopr/game.ex b/lib/wopr/game.ex new file mode 100644 index 0000000..4803a04 --- /dev/null +++ b/lib/wopr/game.ex @@ -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 diff --git a/lib/wopr/game/dist.ex b/lib/wopr/game/dist.ex new file mode 100644 index 0000000..1e7fdb0 --- /dev/null +++ b/lib/wopr/game/dist.ex @@ -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 diff --git a/lib/wopr/game/server.ex b/lib/wopr/game/server.ex new file mode 100644 index 0000000..aed1c1e --- /dev/null +++ b/lib/wopr/game/server.ex @@ -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 diff --git a/lib/wopr/game/state.ex b/lib/wopr/game/state.ex new file mode 100644 index 0000000..269c5f2 --- /dev/null +++ b/lib/wopr/game/state.ex @@ -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 diff --git a/lib/wopr/player.ex b/lib/wopr/player.ex new file mode 100644 index 0000000..5665e0f --- /dev/null +++ b/lib/wopr/player.ex @@ -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 diff --git a/lib/wopr/player/dist.ex b/lib/wopr/player/dist.ex new file mode 100644 index 0000000..87652aa --- /dev/null +++ b/lib/wopr/player/dist.ex @@ -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 diff --git a/lib/wopr/player/server.ex b/lib/wopr/player/server.ex new file mode 100644 index 0000000..ca84376 --- /dev/null +++ b/lib/wopr/player/server.ex @@ -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 diff --git a/lib/wopr/player/state.ex b/lib/wopr/player/state.ex new file mode 100644 index 0000000..b7027ad --- /dev/null +++ b/lib/wopr/player/state.ex @@ -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 diff --git a/lib/wopr/shell.ex b/lib/wopr/shell.ex new file mode 100644 index 0000000..acfa320 --- /dev/null +++ b/lib/wopr/shell.ex @@ -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 diff --git a/lib/wopr/shell/api.ex b/lib/wopr/shell/api.ex new file mode 100644 index 0000000..765368f --- /dev/null +++ b/lib/wopr/shell/api.ex @@ -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 diff --git a/lib/wopr/shell/io.ex b/lib/wopr/shell/io.ex new file mode 100644 index 0000000..50f79b7 --- /dev/null +++ b/lib/wopr/shell/io.ex @@ -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 diff --git a/lib/wopr/shell/server.ex b/lib/wopr/shell/server.ex new file mode 100644 index 0000000..3056247 --- /dev/null +++ b/lib/wopr/shell/server.ex @@ -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 diff --git a/lib/wopr/shell/state.ex b/lib/wopr/shell/state.ex new file mode 100644 index 0000000..abe077e --- /dev/null +++ b/lib/wopr/shell/state.ex @@ -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 diff --git a/mix.exs b/mix.exs index 6e9b1e5..00a2c8e 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 0762413..b1a46a9 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, } diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..dec440f 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ +Mimic.copy(:syn) ExUnit.start() diff --git a/test/wopr/game/dist_test.exs b/test/wopr/game/dist_test.exs new file mode 100644 index 0000000..1f00264 --- /dev/null +++ b/test/wopr/game/dist_test.exs @@ -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 diff --git a/test/wopr/game/state_test.exs b/test/wopr/game/state_test.exs new file mode 100644 index 0000000..bb6c118 --- /dev/null +++ b/test/wopr/game/state_test.exs @@ -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 diff --git a/test/wopr/player/dist_test.exs b/test/wopr/player/dist_test.exs new file mode 100644 index 0000000..eb435e3 --- /dev/null +++ b/test/wopr/player/dist_test.exs @@ -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 diff --git a/wopr b/wopr new file mode 100755 index 0000000..046576d --- /dev/null +++ b/wopr @@ -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