defmodule Hangman.Game do
@moduledoc """
A game struct and functions for the _Hangman Game_.
The game struct contains the fields `game_name`, `game_state`, `turns_left`,
`letters` and `used` representing the characteristics of a game in the
_Hangman Game_.
##### Based on the course [Elixir for Programmers](https://codestool.coding-gnome.com/courses/elixir-for-programmers) by Dave Thomas.
"""
alias __MODULE__
@enforce_keys [:game_name, :letters]
defstruct game_name: "",
game_state: :initializing,
turns_left: 7,
letters: [],
used: MapSet.new()
@typedoc "Letter from `a` to `z`"
@type letter :: String.codepoint()
@typedoc "Game name"
@type name :: String.t()
@typedoc "Game state"
@type state ::
:initializing
| :good_guess
| :bad_guess
| :already_used
| :lost
| :won
@typedoc "A game struct for the Hangman Game"
@type t :: %Game{
game_name: name,
game_state: state,
turns_left: turns_left,
letters: [letter],
used: used
}
@typedoc "A tally map for the Hangman Game"
@type tally :: %{
game_state: state,
turns_left: turns_left,
letters: [letter | underline | charlist],
guesses: [letter]
}
@typedoc "Turns left from 7 to 0"
@type turns_left :: 0..7
@typedoc "Underline: `_`"
@type underline :: String.codepoint()
@typedoc "A set of used (guessed) letters"
@type used :: MapSet.t(letter)
@doc """
Creates a game struct from a `game_name` and a `word` to be guessed. The
default value for `game_name` is provided by function `random_name/0` and for
`word` by function `Hangman.Dictionary.random_word/0`.
## Examples
iex> alias Hangman.Game
iex> game = Game.new()
iex> game_name_length = String.length(game.game_name)
iex> {game.game_state, game.turns_left, game_name_length in 4..10}
{:initializing, 7, true}
iex> alias Hangman.Game
iex> game = Game.new("Mr Smith")
iex> {game.game_state, game.turns_left, game.game_name}
{:initializing, 7, "Mr Smith"}
iex> alias Hangman.Game
iex> game = Game.new("Wibble", "wibble")
iex> {game.game_state, game.turns_left, game.game_name, game.letters}
{:initializing, 7, "Wibble", ~W[w i b b l e]}
"""
@spec new(name, String.t()) :: t
def new(game_name \\ random_name(), word \\ Hangman.Dictionary.random_word()),
do: %Game{game_name: game_name, letters: String.codepoints(word)}
@doc """
Returns a random name of 4 to 10 characters.
## Examples
iex> alias Hangman.Game
iex> for _ <- 0..99, uniq: true do
iex> length = Game.random_name() |> String.length()
iex> length in 4..10
iex> end
[true]
"""
@spec random_name :: name
def random_name do
length = Enum.random(4..10)
:crypto.strong_rand_bytes(length)
|> Base.url_encode64(padding: false)
# Starting at 0 with length "length"...
|> binary_part(0, length)
end
@doc """
Makes a move by guessing a letter.
## Examples
iex> alias Hangman.Game
iex> game = Game.new()
iex> Game.make_move(game, "a").game_state in [:good_guess, :bad_guess]
true
"""
@spec make_move(t, guess :: letter) :: t
def make_move(%Game{game_state: state} = game, _) when state in [:won, :lost],
do: game
# Guess not validated here; should be done in client interface...
def make_move(%Game{used: used} = game, guess),
do: make_move(game, guess, MapSet.member?(used, guess))
@doc """
Returns a tally map externalizing `game`.
## Examples
iex> alias Hangman.Game
iex> game = Game.random_name() |> Game.new("anaconda")
iex> game = Game.make_move(game, "a")
iex> tally = Game.tally(game)
iex> {tally.game_state, tally.turns_left, tally.letters, tally.guesses}
{:good_guess, 7, ~W[a _ a _ _ _ _ a], ~W[a]}
"""
@spec tally(t) :: tally
def tally(%Game{game_state: game_state, turns_left: turns_left} = game) do
%{
game_state: game_state,
turns_left: turns_left,
letters: reveal_guessed_letters(game_state, game.letters, game.used),
guesses: MapSet.to_list(game.used)
}
end
## Private functions
@spec reveal_guessed_letters(state, [letter], used) ::
[letter | underline | charlist]
defp reveal_guessed_letters(:lost = _game_state, letters, used),
do:
letters |> Enum.map(&if MapSet.member?(used, &1), do: &1, else: ~c"#{&1}")
defp reveal_guessed_letters(_game_state, letters, used),
do: letters |> Enum.map(&if MapSet.member?(used, &1), do: &1, else: "_")
@spec make_move(t, letter, boolean) :: t
defp make_move(game, _guess, _already_used? = true),
do: put_in(game.game_state, :already_used)
defp make_move(game, guess, _already_used?) do
update_in(game.used, &MapSet.put(&1, guess))
|> score_guess(guess in game.letters)
end
@spec score_guess(t, boolean) :: t
defp score_guess(game, _good_guess? = true) do
MapSet.new(game.letters)
|> MapSet.subset?(game.used)
|> if(do: :won, else: :good_guess)
|> then(&put_in(game.game_state, &1))
end
defp score_guess(%Game{turns_left: 1} = game, _good_guess?),
do: %Game{game | game_state: :lost, turns_left: 0}
defp score_guess(%Game{turns_left: turns_left} = game, _good_guess?),
do: %Game{game | game_state: :bad_guess, turns_left: turns_left - 1}
end