Hangman - Agent
Modulos requeridos
defmodule Hangman.State do
@moduledoc """
The Hangman game state
"""
@enforce_keys [:word, :goal]
defstruct [
:word,
:goal,
misses: MapSet.new(),
matches: MapSet.new(),
limit: 5,
mask: "_",
completed?: false
]
@type t :: %__MODULE__{
word: String.t(),
goal: MapSet.t(),
misses: MapSet.t(),
matches: MapSet.t(),
limit: pos_integer(),
mask: String.t(),
completed?: boolean()
}
@doc """
Creates the initial game state
"""
@spec new(String.t()) :: t()
def new(word) when is_binary(word) do
word = String.downcase(word)
goal = word |> String.codepoints() |> MapSet.new()
struct!(__MODULE__, word: word, goal: goal)
end
end
defmodule Hangman.GameLogic do
@moduledoc """
Main logic for the game
"""
alias Hangman.State
@doc """
Returns the game state after the user takes a guess
"""
@spec guess(String.t(), State.t()) :: State.t()
def guess(letter, %State{} = state) do
%{goal: goal, matches: matches, misses: misses, limit: limit} = state
if MapSet.member?(goal, letter) do
matches = MapSet.put(matches, letter)
completed? = MapSet.equal?(matches, goal)
%{state | matches: matches, completed?: completed?}
else
%{state | misses: MapSet.put(misses, letter), limit: limit - 1}
end
end
end
defmodule Hangman.Goal.Api do
@moduledoc """
Word generator API
"""
@doc """
Generates a word, phrase, or sentence.
"""
@callback generate() :: String.t()
end
defmodule Hangman.Goal do
@moduledoc """
Goal (word, phrase, sentence) generator entry point
"""
@behaviour Hangman.Goal.Api
@impl true
def generate do
client = Application.get_env(:hangman, :goal_generator, Hangman.Goal.DummyGenerator)
client.generate()
end
end
defmodule Hangman.Goal.DummyGenerator do
@behaviour Hangman.Goal.Api
@impl true
def generate do
Enum.random(["hangman", "letterbox", "wheel of fortune"])
end
end
Agent
Introducción a Agent
Agents ofrecen contenedores simples alrededor de estados. Si lo que necesitas de un proceso es que almacene un estado particular, el módulo Agent es una buena opción.
{:ok, pid} = Agent.start_link(fn -> [] end, name: :milton)
Agent.update(pid, &["eggs" | &1])
Agent.get_and_update(pid, fn state -> {state, ["bacon" | state]} end)
Agent.get(:milton, & &1)
Agent.stop(:milton)
Agent.get(pid, & &1)
Agent.get(:milton, & &1)
Volvamos al juego
Hasta ahora nuestra implementación del juego expone el estado del mismo al jugador, dentro del estado se encuentra la palabra, sentencia u oración objetivo, por lo que no tiene mucho sentido seguir exponiendo dicha información.
Cuando necesitamos mantener una especie de estado en nuestros sistemas, debemos usar alguna abstracción que mantenga el estado por nosotros. Una de esas abstracciones disponibles en Elixir se llama Agent.
defmodule Hangman do
@moduledoc """
The famous Hangman game
"""
use Agent
alias Hangman.GameLogic
alias Hangman.Goal
alias Hangman.State
alias Hangman.View
@doc """
Starts the game
"""
@spec start_link(atom()) :: Agent.on_start()
def start_link(player) when is_atom(player) do
Agent.start_link(
fn -> State.new(Goal.generate()) end,
name: player
)
end
@doc """
Returns the masked word
"""
@spec get(atom() | pid()) :: String.t()
def get(player) do
player
|> Agent.get(& &1)
|> View.format_response()
end
@doc """
Lets the user to take a guess
"""
@spec take_a_guess(atom() | pid(), String.t()) :: String.t()
def take_a_guess(player, letter) do
new_state =
Agent.get_and_update(player, fn state ->
new_state =
letter
|> String.downcase()
|> GameLogic.guess(state)
{new_state, new_state}
end)
View.format_response(new_state)
end
@doc """
Stops the game for the given player
"""
def stop(player), do: Agent.stop(player)
end
Actualicemos nuestra capa de presentación para ocultar el estado.
defmodule Hangman.View do
@moduledoc """
Presentation layer for the Hangman game
"""
alias Hangman.State
@doc """
Returns a human-friendly response
"""
@spec format_response(State.t()) :: String.t()
def format_response(%State{limit: limit, completed?: false} = state) when limit > 0 do
mask_word(state)
end
def format_response(%State{limit: limit, word: word}) when limit > 0 do
"You won, word was: #{word}"
end
def format_response(%State{word: word}) do
"Game Over, word was: #{word}"
end
## Helpers
defp mask_word(%{matches: matches, mask: mask, word: word} = _state) do
if MapSet.size(matches) > 0 do
matches = Enum.join(matches)
String.replace(word, ~r/[^#{matches}]/, mask)
else
String.replace(word, ~r/./, mask)
end
end
end
Veamos como funciona ahora
player = :milton
{:ok, pid} = Hangman.start_link(player)
Nota que si intentamos iniciar un nuevo juego para el mismo jugador, se nos indica que ya se ha iniciado un proceso con el mismo nombre:
Hangman.start_link(player)
IO.inspect(Hangman.get(player))
IO.inspect(Hangman.get(pid))
Nota que podemos usar tanto el PID (Process Identifier) así como un nombre más ameno para hacer referencia al proceso que hemos creado.
Hangman.take_a_guess(player, "a")
Hangman.take_a_guess(player, "h")
Hangman.take_a_guess(player, "m")
Hangman.take_a_guess(player, "n")
Hangman.take_a_guess(player, "g")
Terminemos el juego
Hangman.stop(player)
Si intentamos ejecutar alguna operación para el jugador previo, obtenemos:
Hangman.take_a_guess(player, "z")