defmodule Whisk do
@moduledoc """
A twisty puzzle scramble generator.
## Examples
```
iex> Whisk.scramble("3x3")
"L F2 U R D B U2 R F' R F R2 D2 B R' B' D R B U"
iex> Whisk.scramble("2x2", length: 12)
"R2 F R' U2 F R U2 R2 F2 R F2 U2"
```
"""
@typedoc """
A string representation of a puzzle type.
Currently supported values are:
- `"2x2"`
- `"3x3"`
"""
@type puzzle_type :: String.t()
@typedoc """
A scramble is string representing a sequence of moves, separated by spaces.
Moves are also strings. Different puzzle types have different valid moves.
"""
@type scramble :: String.t()
# A puzzle spec is a 3-tuple containing the following:
# - a list of groups of turns on the same axis (e. g. R and L for cubes)
# - a list of turn modifiers (e. g. for cubes, ', 2, and nothing)
# - the default move count in a scramble
@puzzle_spec_2x2 {[~w(R), ~w(F), ~w(U)], ["", "'", "2"], 10}
@puzzle_spec_3x3 {[~w(R L), ~w(F B), ~w(U D)], ["", "'", "2"], 20}
## API
@doc """
Returns a list of supported puzzle types.
"""
def puzzle_types do
["2x2", "3x3"]
end
@doc """
Generate a scramble for a puzzle type.
Passing an unsupported puzzle type will generate an error.
## Options
- `:length` - the number of moves in the scramble
"""
@spec scramble(puzzle_type(), list()) :: scramble()
def scramble(puzzle_name, opts \\ []) do
{axes, modifiers, default_length} = puzzle_spec(puzzle_name)
length = opts[:length] || default_length
generate_scramble(axes, modifiers, length)
end
## Helpers
defp puzzle_spec("2x2") do
@puzzle_spec_2x2
end
defp puzzle_spec("3x3") do
@puzzle_spec_3x3
end
defp puzzle_spec(_) do
raise "Unsupported puzzle type"
end
defp generate_scramble(axes, modifiers, length) do
axis_idx = :rand.uniform(Enum.count(axes)) - 1
Enum.join(generate_moves(axes, modifiers, axis_idx, [], length), " ")
end
defp generate_moves(axes, modifiers, last_axis_index, acc, remaining) do
if remaining <= 0 do
acc
else
axis_idx = different_axis_index(axes, last_axis_index)
generate_moves(
axes,
modifiers,
axis_idx,
[generate_move(axes, modifiers, axis_idx) | acc],
remaining - 1
)
end
end
defp generate_move(axes, modifiers, axis_idx) do
Enum.random(Enum.at(axes, axis_idx)) <> Enum.random(modifiers)
end
defp different_axis_index(axes, index) do
axis_count = Enum.count(axes)
rem(index + :rand.uniform(axis_count - 1), axis_count)
end
end