basic admin endgame view and add (#3)
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -15,7 +15,9 @@ defmodule Chesstrainer.Application do
|
||||
# Start a worker by calling: Chesstrainer.Worker.start_link(arg)
|
||||
# {Chesstrainer.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
ChesstrainerWeb.Endpoint
|
||||
ChesstrainerWeb.Endpoint,
|
||||
{Registry, keys: :unique, name: Chesstrainer.CacheRegistry},
|
||||
{Chesstrainer.Cache, :tablebase}
|
||||
]
|
||||
|
||||
# See https://elixir.hexdocs.pm/Supervisor.html
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
defmodule Chesstrainer.Cache do
|
||||
@moduledoc """
|
||||
Generic LRU cache.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> start_link(:tablebase)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
@max_size 1000
|
||||
@backup_interval_minutes 60
|
||||
|
||||
def start_link(table), do: GenServer.start_link(__MODULE__, table, name: via_tuple(table))
|
||||
def put(table, key, value), do: GenServer.call(via_tuple(table), {:put, key, value})
|
||||
def get(table, key), do: GenServer.call(via_tuple(table), {:get, key})
|
||||
def delete(table, key), do: GenServer.call(via_tuple(table), {:delete, key})
|
||||
def backup(table), do: GenServer.cast(via_tuple(table), :backup)
|
||||
|
||||
def show(table) do
|
||||
ensure_table(table)
|
||||
:ets.tab2list(table)
|
||||
end
|
||||
|
||||
def size(table), do: :ets.info(table, :size)
|
||||
|
||||
@impl true
|
||||
def init(table) do
|
||||
{:ok, table, {:continue, :restore_and_backup}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(:restore_and_backup, table) do
|
||||
create_table(table)
|
||||
__MODULE__.Persist.restore(table)
|
||||
:timer.send_interval(:timer.minutes(@backup_interval_minutes), :backup)
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:put, key, value}, _from, table) do
|
||||
ensure_table(table)
|
||||
timestamp = System.system_time(:millisecond)
|
||||
:ets.insert(table, {key, value, timestamp})
|
||||
|
||||
GenServer.cast(self(), :maybe_evict)
|
||||
|
||||
{:reply, :ok, table}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get, key}, _from, table) do
|
||||
ensure_table(table)
|
||||
|
||||
case :ets.lookup(table, key) do
|
||||
[{^key, value, _old_timestamp}] ->
|
||||
timestamp = System.system_time(:millisecond)
|
||||
:ets.insert(table, {key, value, timestamp})
|
||||
{:reply, {:ok, value}, table}
|
||||
|
||||
[] ->
|
||||
{:reply, :error, table}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:delete, key}, _from, table) do
|
||||
:ets.delete(table, key)
|
||||
{:reply, :ok, table}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:maybe_evict, table) do
|
||||
__MODULE__.Evict.maybe_evict_oldest(table, @max_size)
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:backup, table) do
|
||||
__MODULE__.Persist.backup(table)
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:backup, table) do
|
||||
GenServer.cast(self(), :backup)
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
defp via_tuple(table), do: {:via, Registry, {Chesstrainer.CacheRegistry, table}}
|
||||
|
||||
defp ensure_table(table) do
|
||||
case :ets.whereis(table) do
|
||||
:undefined -> create_table(table)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp create_table(table), do: :ets.new(table, [:named_table, :public, :set])
|
||||
end
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
defmodule Chesstrainer.Cache.Evict do
|
||||
@moduledoc """
|
||||
Eviction functions for LRU cache
|
||||
"""
|
||||
|
||||
# O(n) time, not efficient
|
||||
def maybe_evict_oldest(table, max_size) do
|
||||
if :ets.info(table, :size) > max_size do
|
||||
case find_oldest_entry(table) do
|
||||
{oldest_key, _} -> :ets.delete(table, oldest_key)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_oldest_entry(table) do
|
||||
:ets.foldl(
|
||||
fn {k, _v, t}, acc ->
|
||||
case acc do
|
||||
nil -> {k, t}
|
||||
{_, oldest_t} when t < oldest_t -> {k, t}
|
||||
_ -> acc
|
||||
end
|
||||
end,
|
||||
nil,
|
||||
table
|
||||
)
|
||||
end
|
||||
end
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
defmodule Chesstrainer.Cache.Persist do
|
||||
@moduledoc """
|
||||
Persistance for cache. Backup and restore functions.
|
||||
"""
|
||||
|
||||
@backup_path "priv/static/backups"
|
||||
|
||||
def backup(table) do
|
||||
File.mkdir_p!(@backup_path)
|
||||
filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist()
|
||||
{:ok, dets_table} = :dets.open_file(filename, type: :set)
|
||||
:ets.to_dets(table, dets_table)
|
||||
:dets.sync(dets_table)
|
||||
end
|
||||
|
||||
def restore(table) do
|
||||
File.mkdir_p!(@backup_path)
|
||||
filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist()
|
||||
|
||||
case :dets.open_file(table, file: filename, type: :set) do
|
||||
{:ok, ^table} ->
|
||||
:dets.foldl(fn entry, _ -> :ets.insert(table, entry) end, :ok, table)
|
||||
:dets.close(table)
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
defmodule Chesstrainer.Endgames do
|
||||
@moduledoc """
|
||||
The Endgames context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Chesstrainer.Repo
|
||||
|
||||
alias Chesstrainer.Endgames.Endgame
|
||||
|
||||
@doc """
|
||||
Returns the list of endgames.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_endgames()
|
||||
[%Endgame{}, ...]
|
||||
|
||||
"""
|
||||
def list_endgames do
|
||||
Repo.all(Endgame)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single endgame.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Endgame does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_endgame!(123)
|
||||
%Endgame{}
|
||||
|
||||
iex> get_endgame!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_endgame!(id), do: Repo.get!(Endgame, id)
|
||||
|
||||
@doc """
|
||||
Creates a endgame.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_endgame(%{field: value})
|
||||
{:ok, %Endgame{}}
|
||||
|
||||
iex> create_endgame(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_endgame(attrs) do
|
||||
%Endgame{}
|
||||
|> Endgame.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a endgame.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_endgame(endgame, %{field: new_value})
|
||||
{:ok, %Endgame{}}
|
||||
|
||||
iex> update_endgame(endgame, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_endgame(%Endgame{} = endgame, attrs) do
|
||||
# ignore fen on update
|
||||
attrs = Map.delete(attrs, "fen")
|
||||
|
||||
endgame
|
||||
|> Endgame.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a endgame.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_endgame(endgame)
|
||||
{:ok, %Endgame{}}
|
||||
|
||||
iex> delete_endgame(endgame)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_endgame(%Endgame{} = endgame) do
|
||||
Repo.delete(endgame)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking endgame changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_endgame(endgame)
|
||||
%Ecto.Changeset{data: %Endgame{}}
|
||||
|
||||
"""
|
||||
def change_endgame(%Endgame{} = endgame, attrs \\ %{}) do
|
||||
Endgame.changeset(endgame, attrs)
|
||||
end
|
||||
|
||||
def add_tag(endgame, tag) do
|
||||
endgame
|
||||
|> Repo.preload(:tags)
|
||||
|> Ecto.Changeset.change()
|
||||
|> Ecto.Changeset.put_assoc(:tags, [tag | endgame.tags])
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def remove_tag(endgame, tag) do
|
||||
endgame
|
||||
|> Repo.preload(:tags)
|
||||
|> Ecto.Changeset.change()
|
||||
|> Ecto.Changeset.put_assoc(:tags, endgame.tags -- [tag])
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
defmodule Chesstrainer.Endgames.Endgame do
|
||||
@moduledoc """
|
||||
A singular endgame entry
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "endgames" do
|
||||
field :fen, :string
|
||||
field :color, Ecto.Enum, values: [:white, :black]
|
||||
field :key, :string
|
||||
field :message, :string
|
||||
field :notes, :string
|
||||
field :result, Ecto.Enum, values: [:win, :loss, :draw]
|
||||
field :rating, :integer
|
||||
field :rating_deviation, :integer
|
||||
field :times_attempted, :integer
|
||||
field :times_solved, :integer
|
||||
|
||||
many_to_many :tags, Chesstrainer.Tags.Tag,
|
||||
join_through: "endgames_tags",
|
||||
on_replace: :delete
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(endgame, attrs) do
|
||||
endgame
|
||||
|> cast(attrs, [:fen, :key, :message, :notes, :result, :rating, :color])
|
||||
|> validate_required([:fen, :color], message: "Invalid FEN")
|
||||
|> validate_required([:key, :result, :rating])
|
||||
|> validate_format(:key, ~r/^(?=.{5,10}$)KQ*R*[NB]*P*\sv\sKQ*R*[NB]*P*$/,
|
||||
message: "Key must follow pattern KQ.. v KQR.., max pieces 7"
|
||||
)
|
||||
|> unique_constraint(:fen, message: "FEN already exists")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
defmodule Chesstrainer.Endgames.Tablebase do
|
||||
@moduledoc """
|
||||
All endgame tablebase related functions.
|
||||
"""
|
||||
|
||||
alias Chesstrainer.Endgames.Tablebase
|
||||
|
||||
@type result :: :win | :draw | :loss | :unknown
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
category: result,
|
||||
checkmate: boolean(),
|
||||
dtc: integer() | nil,
|
||||
dtm: integer() | nil,
|
||||
dtw: integer() | nil,
|
||||
dtz: integer() | nil,
|
||||
insufficient_material: boolean(),
|
||||
moves: [Tablebase.Move.t()]
|
||||
}
|
||||
|
||||
defstruct category: nil,
|
||||
checkmate: false,
|
||||
dtc: nil,
|
||||
dtm: nil,
|
||||
dtw: nil,
|
||||
dtz: nil,
|
||||
insufficient_material: false,
|
||||
moves: nil
|
||||
|
||||
@spec new(String.t()) ::
|
||||
{:ok, t()}
|
||||
| {:cooldown, non_neg_integer()}
|
||||
| {:error, :invalid_endgame}
|
||||
| {:error, term()}
|
||||
def new(fen) do
|
||||
case Tablebase.Cache.get(fen) do
|
||||
{_fen, tablebase} ->
|
||||
{:cache, tablebase}
|
||||
|
||||
_ ->
|
||||
case Tablebase.Parser.from_fen(fen) do
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
{:cooldown, remaining_ms} ->
|
||||
{:cooldown, remaining_ms}
|
||||
|
||||
{:ok, tablebase} when tablebase.category == :unknown ->
|
||||
{:error, :invalid_endgame}
|
||||
|
||||
{:ok, tablebase} ->
|
||||
Tablebase.Cache.put(fen, tablebase)
|
||||
{:ok, tablebase}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule Chesstrainer.Endgames.Tablebase.Cache do
|
||||
@moduledoc """
|
||||
A cache of tablebases by FEN.
|
||||
"""
|
||||
|
||||
alias Chesstrainer.Cache
|
||||
|
||||
@table :tablebase
|
||||
|
||||
def put(fen, tablebase), do: Cache.put(@table, fen, tablebase)
|
||||
|
||||
def get(fen), do: Cache.get(@table, fen)
|
||||
|
||||
def delete(fen), do: Cache.delete(@table, fen)
|
||||
def show, do: Cache.show(@table)
|
||||
def size, do: Cache.size(@table)
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
defmodule Chesstrainer.Endgames.Tablebase.Move do
|
||||
@moduledoc """
|
||||
Moves struct for the tablebase
|
||||
"""
|
||||
|
||||
@type result :: :win | :draw | :loss
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
category: result,
|
||||
checkmate: boolean(),
|
||||
conversion: boolean(),
|
||||
dtc: integer() | nil,
|
||||
dtm: integer() | nil,
|
||||
dtw: integer() | nil,
|
||||
dtz: integer() | nil,
|
||||
precise_dtz: integer() | nil,
|
||||
san: String.t() | nil,
|
||||
stalemate: boolean(),
|
||||
uci: String.t() | nil,
|
||||
variant_loss: boolean(),
|
||||
variant_win: boolean(),
|
||||
zeroing: boolean(),
|
||||
insufficient_material: boolean()
|
||||
}
|
||||
|
||||
defstruct category: nil,
|
||||
checkmate: false,
|
||||
conversion: false,
|
||||
dtc: nil,
|
||||
dtm: nil,
|
||||
dtw: nil,
|
||||
dtz: nil,
|
||||
precise_dtz: nil,
|
||||
san: nil,
|
||||
stalemate: false,
|
||||
uci: nil,
|
||||
variant_loss: false,
|
||||
variant_win: false,
|
||||
zeroing: false,
|
||||
insufficient_material: false
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
defmodule Chesstrainer.Endgames.Tablebase.Parser do
|
||||
@moduledoc """
|
||||
Fetch and parse tablebase data to fit the tablebase struct
|
||||
|
||||
Lichess tablebase response from FEN string.
|
||||
https://lichess.org/api#tag/tablebase
|
||||
|
||||
FEN must have spaces replaced by underscores to satisfy the Lichess API.
|
||||
"""
|
||||
|
||||
import Chesstrainer.Ratelimiters
|
||||
alias Chesstrainer.Endgames.Tablebase
|
||||
|
||||
@lichess_tablebase_url "http://tablebase.lichess.ovh/standard?fen="
|
||||
|
||||
@spec from_fen(String.t()) ::
|
||||
{:ok, Tablebase.t()} | {:cooldown, non_neg_integer()} | {:error, term()}
|
||||
def from_fen(fen) do
|
||||
case lichess_check_cooldown() do
|
||||
{:ok, 0} -> tablebase_response(fen)
|
||||
{:cooldown, remaining_ms} -> {:cooldown, remaining_ms}
|
||||
end
|
||||
end
|
||||
|
||||
@spec tablebase_response(String.t()) :: {:ok, Tablebase.t()} | {:error, term()}
|
||||
defp tablebase_response(fen) do
|
||||
response =
|
||||
fen
|
||||
|> sanitise_fen()
|
||||
|> build_request_string()
|
||||
|> Req.get()
|
||||
|> parse_response()
|
||||
|
||||
case response do
|
||||
{:ok, body} -> from_map(body)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# exposed for testing
|
||||
def from_map(map) do
|
||||
{:ok,
|
||||
%Tablebase{
|
||||
category: map["category"] |> String.to_atom(),
|
||||
checkmate: map["checkmate"],
|
||||
dtc: map["dtc"],
|
||||
dtm: map["dtm"],
|
||||
dtw: map["dtw"],
|
||||
dtz: map["dtz"],
|
||||
insufficient_material: map["insufficient_material"],
|
||||
moves: Enum.map(map["moves"], &moves_from_map/1)
|
||||
}}
|
||||
end
|
||||
|
||||
defp moves_from_map(map) do
|
||||
%Tablebase.Move{
|
||||
category: map["category"] |> String.to_atom(),
|
||||
checkmate: map["checkmate"],
|
||||
conversion: map["conversion"],
|
||||
dtc: map["dtc"],
|
||||
dtm: map["dtm"],
|
||||
dtw: map["dtw"],
|
||||
dtz: map["dtz"],
|
||||
insufficient_material: map["insufficient_material"],
|
||||
precise_dtz: map["precise_dtz"],
|
||||
san: map["san"],
|
||||
stalemate: map["stalemate"],
|
||||
uci: map["uci"],
|
||||
variant_loss: map["variant_loss"],
|
||||
variant_win: map["variant_win"],
|
||||
zeroing: map["zeroing"]
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_response(response) do
|
||||
case response do
|
||||
# Success
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
{:ok, body}
|
||||
|
||||
# Client errors
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 400..499 ->
|
||||
{:error, {:client_error, status, body}}
|
||||
|
||||
# Rate limiting
|
||||
{:ok, %Req.Response{status: 429, body: body}} ->
|
||||
lichess_add_cooldown()
|
||||
{:error, {:too_many_requests, 429, body}}
|
||||
|
||||
# Server errors
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 500..599 ->
|
||||
{:error, {:server_error, status, body}}
|
||||
|
||||
# Catch‑all for other status codes
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
{:error, {:http_error, status, body}}
|
||||
|
||||
# Network / client‑side failure
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitise_fen(fen), do: String.replace(fen, " ", "_")
|
||||
defp build_request_string(fen), do: "#{@lichess_tablebase_url}#{fen}"
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
defmodule Chesstrainer.FEN do
|
||||
@spec color_from_fen(String.t()) :: :white | :black | nil
|
||||
def color_from_fen(fen) do
|
||||
fen
|
||||
|> String.split(" ")
|
||||
|> Enum.at(1)
|
||||
|> color_initial_to_color_atom()
|
||||
end
|
||||
|
||||
defp color_initial_to_color_atom("b"), do: :black
|
||||
defp color_initial_to_color_atom("w"), do: :white
|
||||
defp color_initial_to_color_atom(_), do: nil
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
defmodule Chesstrainer.Game do
|
||||
@moduledoc """
|
||||
Game related functions
|
||||
"""
|
||||
alias Chesstrainer.Endgames
|
||||
|
||||
@type game_type :: :endgame
|
||||
@type square :: {atom(), pos_integer()}
|
||||
@type move :: {square, square}
|
||||
@type colors :: :white | :black
|
||||
@type game_state :: :continue | :loss | :draw
|
||||
@type result :: :win | :draw | :loss
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
player_color: colors,
|
||||
board: map(),
|
||||
active_color: colors,
|
||||
castling: list(),
|
||||
en_passant: term() | nil,
|
||||
moves: list(),
|
||||
halfmove_clock: non_neg_integer(),
|
||||
fullmove_clock: non_neg_integer(),
|
||||
captures: list(),
|
||||
check: term() | nil,
|
||||
result: result,
|
||||
pgn: String.t() | nil,
|
||||
orientation: colors,
|
||||
move_from_square: square,
|
||||
move_to_square: square,
|
||||
game_type: game_type,
|
||||
fen: String.t(),
|
||||
tablebase: term(),
|
||||
game_state: game_state
|
||||
}
|
||||
|
||||
defstruct board: %{},
|
||||
player_color: :white,
|
||||
active_color: :white,
|
||||
castling: [],
|
||||
en_passant: nil,
|
||||
moves: [],
|
||||
halfmove_clock: 0,
|
||||
fullmove_clock: 0,
|
||||
captures: [],
|
||||
check: nil,
|
||||
result: nil,
|
||||
pgn: nil,
|
||||
orientation: :white,
|
||||
move_from_square: nil,
|
||||
move_to_square: nil,
|
||||
game_type: nil,
|
||||
fen: nil,
|
||||
tablebase: nil,
|
||||
game_state: :continue
|
||||
|
||||
@doc """
|
||||
Builds a `%ChessTrainer.Game{}` from a FEN string.
|
||||
|
||||
On success, returns `{:ok, game}` with the parsed board state.
|
||||
|
||||
If the FEN string is invalid, `Chex.Parser.FEN.parse/1` raises a
|
||||
`MatchError`. This function rescues that exception and returns
|
||||
`{:error, :invalid_fen, game}`, where `game` is a fallback position
|
||||
containing only kings.
|
||||
|
||||
This is a bad implementation in the Chex libary and should be fixed at our fork:
|
||||
https://github.com/therealowenrees/chex
|
||||
|
||||
We should return something like{:ok, game} | {:error | :invalid_fen}
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ChessTrainer.Game.game_from_fen("valid fen", :endgame)
|
||||
{:ok, %ChessTrainer.Game{}}
|
||||
|
||||
iex> ChessTrainer.Game.game_from_fen("invalid fen", :endgame)
|
||||
{:error, :invalid_fen, %ChessTrainer.Game{}}
|
||||
|
||||
"""
|
||||
@dialyzer {:nowarn_function, game_from_fen: 2}
|
||||
@spec game_from_fen(String.t(), game_type()) :: {:ok, t()} | {:error, :invalid_fen, t()}
|
||||
def game_from_fen(fen, game_type) do
|
||||
try do
|
||||
{:ok, %Chex.Game{} = chex_game} = Chex.Parser.FEN.parse(fen)
|
||||
|
||||
game = %__MODULE__{
|
||||
board: chex_game.board,
|
||||
player_color: chex_game.active_color,
|
||||
active_color: chex_game.active_color,
|
||||
castling: chex_game.castling,
|
||||
en_passant: chex_game.en_passant,
|
||||
moves: chex_game.moves,
|
||||
halfmove_clock: chex_game.halfmove_clock,
|
||||
fullmove_clock: chex_game.fullmove_clock,
|
||||
captures: chex_game.captures,
|
||||
check: chex_game.check,
|
||||
result: chex_game.result,
|
||||
pgn: chex_game.pgn,
|
||||
orientation: chex_game.active_color,
|
||||
move_from_square: nil,
|
||||
move_to_square: nil,
|
||||
game_type: game_type,
|
||||
fen: fen,
|
||||
game_state: :continue,
|
||||
tablebase:
|
||||
if game_type === :endgame do
|
||||
Endgames.Tablebase.new(fen)
|
||||
else
|
||||
nil
|
||||
end
|
||||
}
|
||||
|
||||
{:ok, game}
|
||||
rescue
|
||||
# if a MatchError error is raised, then return a game with only kings on the board
|
||||
MatchError ->
|
||||
game = %__MODULE__{
|
||||
board: %{
|
||||
{:e, 1} => {:king, :white, {:e, 1}},
|
||||
{:e, 8} => {:king, :black, {:e, 8}}
|
||||
},
|
||||
player_color: :white,
|
||||
active_color: :white,
|
||||
castling: [],
|
||||
en_passant: nil,
|
||||
moves: [],
|
||||
halfmove_clock: 0,
|
||||
fullmove_clock: 0,
|
||||
captures: nil,
|
||||
check: nil,
|
||||
result: :draw,
|
||||
pgn: nil,
|
||||
orientation: :white,
|
||||
move_from_square: nil,
|
||||
move_to_square: nil,
|
||||
game_type: :endgame,
|
||||
fen: "4k3/8/8/8/8/8/8/4K3 w - - 0 1",
|
||||
game_state: :draw,
|
||||
tablebase: nil
|
||||
}
|
||||
|
||||
{:error, :invalid_fen, game}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
defmodule Chesstrainer.Ratelimiters do
|
||||
@moduledoc """
|
||||
Generic rate limiter for external APIs (e.g., Lichess).
|
||||
|
||||
This module exposes convenience functions for each external API
|
||||
that delegate to their specific rate limiter implementation.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> ChessTrainer.Ratelimiters.lichess_reset_cooldown()
|
||||
true
|
||||
|
||||
iex> ChessTrainer.Ratelimiters.lichess_check_cooldown()
|
||||
{:ok, 0}
|
||||
|
||||
iex> ChessTrainer.Ratelimiters.lichess_add_cooldown()
|
||||
true
|
||||
"""
|
||||
|
||||
alias Chesstrainer.Ratelimiters.Lichess
|
||||
|
||||
@doc """
|
||||
Adds a cooldown entry for the Lichess API.
|
||||
|
||||
This sets the cooldown period (e.g. 60 seconds) starting from now.
|
||||
Useful when a `429 Too Many Requests` response is received.
|
||||
"""
|
||||
@spec lichess_add_cooldown() :: boolean()
|
||||
def lichess_add_cooldown, do: Lichess.add_cooldown()
|
||||
|
||||
@doc """
|
||||
Resets the cooldown for the Lichess API.
|
||||
|
||||
This clears any existing cooldown, allowing requests immediately.
|
||||
"""
|
||||
@spec lichess_reset_cooldown() :: boolean()
|
||||
def lichess_reset_cooldown, do: Lichess.reset_cooldown()
|
||||
|
||||
@doc """
|
||||
Checks whether the Lichess API is currently under cooldown.
|
||||
|
||||
Returns:
|
||||
* `{:ok, 0}` if no cooldown is active
|
||||
* `{:cooldown, remaining_ms}` if still cooling down, with the remaining time in milliseconds
|
||||
"""
|
||||
@spec lichess_check_cooldown() :: {:ok, non_neg_integer} | {:cooldown, non_neg_integer}
|
||||
def lichess_check_cooldown, do: Lichess.check_cooldown()
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
defmodule Chesstrainer.Ratelimiters.Lichess do
|
||||
@moduledoc """
|
||||
Global rate limiter for 429 response codes from the Lichess API.
|
||||
"""
|
||||
|
||||
@cooldown_seconds 60
|
||||
@table :lichess_cooldown
|
||||
|
||||
def add_cooldown do
|
||||
ensure_table()
|
||||
insert_cooldown()
|
||||
end
|
||||
|
||||
def reset_cooldown do
|
||||
ensure_table()
|
||||
:ets.insert(@table, {:until, nil})
|
||||
end
|
||||
|
||||
def check_cooldown do
|
||||
ensure_table()
|
||||
current_ms = System.system_time(:millisecond)
|
||||
|
||||
case :ets.lookup(@table, :until) do
|
||||
[{:until, nil}] ->
|
||||
{:ok, 0}
|
||||
|
||||
[{:until, until_ms}] ->
|
||||
remaining = until_ms - current_ms
|
||||
|
||||
if remaining <= 0 do
|
||||
reset_cooldown()
|
||||
{:ok, 0}
|
||||
else
|
||||
{:cooldown, remaining}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp create do
|
||||
:ets.new(@table, [:named_table, :public, :set])
|
||||
:ets.insert(@table, {:until, nil})
|
||||
end
|
||||
|
||||
defp ensure_table do
|
||||
case :ets.whereis(@table) do
|
||||
:undefined -> create()
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_cooldown do
|
||||
until_ms = System.system_time(:millisecond) + @cooldown_seconds * 1000
|
||||
:ets.insert(@table, {:until, until_ms})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
defmodule Chesstrainer.Tags do
|
||||
@moduledoc """
|
||||
The Tags context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Chesstrainer.Repo
|
||||
|
||||
alias Chesstrainer.Tags.Tag
|
||||
|
||||
@doc """
|
||||
Returns the list of tags.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_tags()
|
||||
[%Tag{}, ...]
|
||||
|
||||
"""
|
||||
def list_tags do
|
||||
Repo.all(Tag)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single tag.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Tag does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_tag!(123)
|
||||
%Tag{}
|
||||
|
||||
iex> get_tag!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_tag!(id), do: Repo.get!(Tag, id)
|
||||
|
||||
@doc """
|
||||
Creates a tag.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_tag(%{field: value})
|
||||
{:ok, %Tag{}}
|
||||
|
||||
iex> create_tag(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_tag(attrs) do
|
||||
%Tag{}
|
||||
|> Tag.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a tag.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_tag(tag, %{field: new_value})
|
||||
{:ok, %Tag{}}
|
||||
|
||||
iex> update_tag(tag, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_tag(%Tag{} = tag, attrs) do
|
||||
tag
|
||||
|> Tag.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a tag.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_tag(tag)
|
||||
{:ok, %Tag{}}
|
||||
|
||||
iex> delete_tag(tag)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_tag(%Tag{} = tag) do
|
||||
Repo.delete(tag)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking tag changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_tag(tag)
|
||||
%Ecto.Changeset{data: %Tag{}}
|
||||
|
||||
"""
|
||||
def change_tag(%Tag{} = tag, attrs \\ %{}) do
|
||||
Tag.changeset(tag, attrs)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Chesstrainer.Tags.Tag do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "tags" do
|
||||
field :name, :string
|
||||
field :category, Ecto.Enum, values: [endgame: "endgame", opening: "opening", tactic: "tactic"]
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(tag, attrs) do
|
||||
tag
|
||||
|> cast(attrs, [:name, :category])
|
||||
|> validate_required([:name, :category])
|
||||
|> unique_constraint(:name)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
defmodule ChesstrainerWeb.Board.BoardComponent do
|
||||
@moduledoc """
|
||||
Chess board component
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
import ChesstrainerWeb.Board.SquareComponent
|
||||
|
||||
attr :game, :map, required: true
|
||||
|
||||
attr :myself, :any, required: true
|
||||
|
||||
def board(assigns) do
|
||||
files_list = [:a, :b, :c, :d, :e, :f, :g, :h]
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:files_list, files_list)
|
||||
|> assign(
|
||||
:files,
|
||||
case assigns.game.orientation do
|
||||
:white -> files_list
|
||||
:black -> Enum.reverse(files_list)
|
||||
end
|
||||
)
|
||||
|> assign(
|
||||
:ranks,
|
||||
case assigns.game.orientation do
|
||||
:white -> Enum.reverse(1..8)
|
||||
:black -> 1..8
|
||||
end
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="w-[388px] m-auto left-0 right-0 mt-6">
|
||||
<div class="border border-2 border-zinc-700 grid grid-rows-8 grid-cols-8">
|
||||
<%= for rank <- @ranks do %>
|
||||
<%= for file <- @files do %>
|
||||
<% square = {file, rank} %>
|
||||
<% piece = Map.get(@game.board, square) %>
|
||||
<.square
|
||||
file={file}
|
||||
rank={rank}
|
||||
piece={piece}
|
||||
files_list={@files_list}
|
||||
move_from_square={@game.move_from_square}
|
||||
myself={@myself}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
defmodule ChesstrainerWeb.Board.PieceComponent do
|
||||
@moduledoc """
|
||||
Chess pieces component
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :piece, :any, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
def piece(assigns) do
|
||||
assigns = assign(assigns, :path, piece_path(assigns.piece))
|
||||
|
||||
~H"""
|
||||
<%= if @path do %>
|
||||
<img src={@path} class={@class} />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
def piece_path({:king, :white, _square}), do: "/images/pieces/white/king.svg"
|
||||
def piece_path({:queen, :white, _square}), do: "/images/pieces/white/queen.svg"
|
||||
def piece_path({:rook, :white, _square}), do: "/images/pieces/white/rook.svg"
|
||||
def piece_path({:bishop, :white, _square}), do: "/images/pieces/white/bishop.svg"
|
||||
def piece_path({:knight, :white, _square}), do: "/images/pieces/white/knight.svg"
|
||||
def piece_path({:pawn, :white, _square}), do: "/images/pieces/white/pawn.svg"
|
||||
def piece_path({:king, :black, _square}), do: "/images/pieces/black/king.svg"
|
||||
def piece_path({:queen, :black, _square}), do: "/images/pieces/black/queen.svg"
|
||||
def piece_path({:rook, :black, _square}), do: "/images/pieces/black/rook.svg"
|
||||
def piece_path({:bishop, :black, _square}), do: "/images/pieces/black/bishop.svg"
|
||||
def piece_path({:knight, :black, _square}), do: "/images/pieces/black/knight.svg"
|
||||
def piece_path({:pawn, :black, _square}), do: "/images/pieces/black/pawn.svg"
|
||||
def piece_path(_), do: nil
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
defmodule ChesstrainerWeb.Board.SquareComponent do
|
||||
@moduledoc """
|
||||
Renders a single chess square with optional piece and highlighting.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import ChesstrainerWeb.Board.PieceComponent
|
||||
|
||||
attr :file, :atom, required: true
|
||||
attr :rank, :integer, required: true
|
||||
attr :piece, :any, default: nil
|
||||
attr :files_list, :list, required: true
|
||||
attr :move_from_square, :any, default: nil
|
||||
attr :myself, :any, required: true
|
||||
|
||||
def square(assigns) do
|
||||
current_square = {assigns.file, assigns.rank}
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:highlight_square, current_square === assigns.move_from_square)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"w-12 h-12 flex items-center justify-center",
|
||||
background(
|
||||
Enum.find_index(@files_list, fn f -> f == @file end),
|
||||
@rank - 1
|
||||
),
|
||||
highlight_square(@highlight_square)
|
||||
]}
|
||||
phx-click="square-click"
|
||||
phx-value-file={@file}
|
||||
phx-value-rank={@rank}
|
||||
phx-value-type="move"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.piece piece={@piece} class="w-10 h-10" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def background(file_idx, rank_idx) when rem(file_idx + rank_idx, 2) != 0, do: "bg-boardwhite"
|
||||
def background(_, _), do: "bg-boardblack"
|
||||
|
||||
def highlight_square(true), do: "ring-inset ring-2 ring-yellow-400"
|
||||
def highlight_square(false), do: ""
|
||||
end
|
||||
@@ -0,0 +1,212 @@
|
||||
defmodule ChesstrainerWeb.EndgameLive.Form do
|
||||
use ChesstrainerWeb, :live_view
|
||||
|
||||
alias Chesstrainer.Repo
|
||||
alias Chesstrainer.Tags
|
||||
alias Chesstrainer.FEN
|
||||
alias Chesstrainer.Endgames
|
||||
alias Chesstrainer.Endgames.Endgame
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Use this form to manage endgame records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="endgame-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:fen]} type="text" label="Fen" disabled={@live_action == :edit} />
|
||||
<.input field={@form[:color]} type="text" label="Color" disabled />
|
||||
<.input field={@form[:key]} type="text" label="Key" />
|
||||
<.input field={@form[:rating]} type="number" label="Rating" />
|
||||
|
||||
<!-- ✅ TAG SELECTOR -->
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
|
||||
<!-- Search box -->
|
||||
<input
|
||||
type="text"
|
||||
name="tag_search"
|
||||
value={@tag_query}
|
||||
phx-change="search_tags"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<!-- Search results -->
|
||||
<ul class="tag-results">
|
||||
<%= for tag <- @tag_results do %>
|
||||
<li phx-click="add_tag" phx-value-id={tag.id}>
|
||||
{tag.name}
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<%= if @tag_query != "" and @tag_results == [] do %>
|
||||
<li phx-click="create_tag" phx-value-name={@tag_query}>
|
||||
Create tag "{@tag_query}"
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<!-- Selected tags -->
|
||||
<div class="selected-tags">
|
||||
<%= for tag <- @endgame.tags do %>
|
||||
<span class="tag">
|
||||
{tag.name}
|
||||
<button type="button" phx-click="remove_tag" phx-value-id={tag.id}>×</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.input field={@form[:message]} type="textarea" label="Message" />
|
||||
<.input field={@form[:notes]} type="textarea" label="Notes" />
|
||||
<.input
|
||||
field={@form[:result]}
|
||||
type="select"
|
||||
options={[
|
||||
{"Win", :win},
|
||||
{"Draw", :draw},
|
||||
{"Loss", :loss}
|
||||
]}
|
||||
label="Result"
|
||||
/>
|
||||
<footer>
|
||||
<.button phx-disable-with="Saving..." variant="primary">Save Endgame</.button>
|
||||
<.button navigate={return_path(@return_to, @endgame)}>Cancel</.button>
|
||||
</footer>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
endgame = Endgames.get_endgame!(id)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Edit Endgame")
|
||||
|> assign(:endgame, endgame)
|
||||
|> assign(:tag_query, "")
|
||||
|> assign(:tag_results, [])
|
||||
|> assign(:endgame, Repo.preload(endgame, :tags))
|
||||
|> assign(:form, to_form(Endgames.change_endgame(endgame)))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
endgame = %Endgame{}
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "New Endgame")
|
||||
|> assign(:endgame, endgame)
|
||||
|> assign(:tag_query, "")
|
||||
|> assign(:tag_results, [])
|
||||
|> assign(:endgame, Repo.preload(endgame, :tags))
|
||||
|> assign(:form, to_form(Endgames.change_endgame(endgame)))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search_tags", %{"tag_search" => query}, socket) do
|
||||
tags = Tags.search(query, "endgame")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tag_query, query)
|
||||
|> assign(:tag_results, tags)}
|
||||
end
|
||||
|
||||
def handle_event("add_tag", %{"id" => id}, socket) do
|
||||
tag = Tags.get_tag!(id)
|
||||
{:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:endgame, Repo.preload(endgame, :tags))
|
||||
|> assign(:tag_query, "")
|
||||
|> assign(:tag_results, [])}
|
||||
end
|
||||
|
||||
def handle_event("create_tag", %{"name" => name}, socket) do
|
||||
{:ok, tag} = Tags.get_or_create(name, "endgame")
|
||||
{:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:endgame, Repo.preload(endgame, :tags))
|
||||
|> assign(:tag_query, "")
|
||||
|> assign(:tag_results, [])}
|
||||
end
|
||||
|
||||
def handle_event("remove_tag", %{"id" => id}, socket) do
|
||||
tag = Tags.get_tag!(id)
|
||||
{:ok, endgame} = Endgames.remove_tag(socket.assigns.endgame, tag)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:endgame, Repo.preload(endgame, :tags))}
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"endgame" => endgame_params}, socket) do
|
||||
# Compute color from the incoming FEN
|
||||
color = FEN.color_from_fen(endgame_params["fen"])
|
||||
|
||||
# Update the struct
|
||||
endgame = %{socket.assigns.endgame | color: color}
|
||||
|
||||
# Build the changeset *from the updated struct*
|
||||
changeset =
|
||||
endgame
|
||||
|> Endgames.change_endgame()
|
||||
|> Ecto.Changeset.put_change(:color, color)
|
||||
|
||||
{:noreply, assign(socket, endgame: endgame, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"endgame" => endgame_params}, socket) do
|
||||
save_endgame(socket, socket.assigns.live_action, endgame_params)
|
||||
end
|
||||
|
||||
defp save_endgame(socket, :edit, endgame_params) do
|
||||
case Endgames.update_endgame(socket.assigns.endgame, endgame_params) do
|
||||
{:ok, endgame} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Endgame updated successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, endgame))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_endgame(socket, :new, endgame_params) do
|
||||
color = FEN.color_from_fen(endgame_params["fen"])
|
||||
params = Map.put(endgame_params, "color", color)
|
||||
|
||||
case Endgames.create_endgame(params) do
|
||||
{:ok, endgame} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Endgame created successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, endgame))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp return_path("index", _endgame), do: ~p"/admin/endgames"
|
||||
defp return_path("show", endgame), do: ~p"/admin/endgames/#{endgame}"
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule ChesstrainerWeb.EndgameLive.Index do
|
||||
use ChesstrainerWeb, :live_view
|
||||
|
||||
alias Chesstrainer.Endgames
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
Listing Endgames
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/admin/endgames/new"}>
|
||||
<.icon name="hero-plus" /> New Endgame
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="endgames"
|
||||
rows={@streams.endgames}
|
||||
row_click={fn {_id, endgame} -> JS.navigate(~p"/admin/endgames/#{endgame}") end}
|
||||
>
|
||||
<:col :let={{_id, endgame}} label="Fen">{endgame.fen}</:col>
|
||||
<:col :let={{_id, endgame}} label="Color">{endgame.color}</:col>
|
||||
<:col :let={{_id, endgame}} label="Key">{endgame.key}</:col>
|
||||
<:col :let={{_id, endgame}} label="Rating">{endgame.rating}</:col>
|
||||
<:col :let={{_id, endgame}} label="Message">{endgame.message}</:col>
|
||||
<:col :let={{_id, endgame}} label="Notes">{endgame.notes}</:col>
|
||||
<:col :let={{_id, endgame}} label="Result">{endgame.result}</:col>
|
||||
<:col :let={{_id, endgame}} label="Attempted">{endgame.times_attempted}</:col>
|
||||
<:col :let={{_id, endgame}} label="Solved">{endgame.times_solved}</:col>
|
||||
<:action :let={{_id, endgame}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link>
|
||||
</div>
|
||||
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
<:action :let={{id, endgame}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: endgame.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Endgames")
|
||||
|> stream(:endgames, list_endgames())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
endgame = Endgames.get_endgame!(id)
|
||||
{:ok, _} = Endgames.delete_endgame(endgame)
|
||||
|
||||
{:noreply, stream_delete(socket, :endgames, endgame)}
|
||||
end
|
||||
|
||||
defp list_endgames() do
|
||||
Endgames.list_endgames()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
defmodule ChesstrainerWeb.EndgameLive.Show do
|
||||
use ChesstrainerWeb, :live_view
|
||||
|
||||
alias Chesstrainer.Endgames
|
||||
alias Chesstrainer.Game
|
||||
|
||||
import ChesstrainerWeb.Board.BoardComponent
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
game =
|
||||
case Game.game_from_fen(assigns.endgame.fen, :endgame) do
|
||||
{:ok, g} -> g
|
||||
{:error, :invalid_fen, g} -> g
|
||||
end
|
||||
|
||||
# TODO once we return {:error, :invalid_fen, game}, then put flash to show it is invalid
|
||||
# write tests for this
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:game, game)
|
||||
|> assign(:game_type, :endgame)
|
||||
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
Endgame {@endgame.id}
|
||||
<:subtitle>This is a endgame record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.button navigate={~p"/admin/endgames"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> Edit endgame
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.board game={@game} myself="board" />
|
||||
|
||||
<.list>
|
||||
<:item title="Fen">{@endgame.fen}</:item>
|
||||
<:item title="Key">{@endgame.key}</:item>
|
||||
<:item title="Color">{@endgame.color}</:item>
|
||||
<:item title="Rating">{@endgame.rating}</:item>
|
||||
<:item title="Message">{@endgame.message}</:item>
|
||||
<:item title="Notes">{@endgame.notes}</:item>
|
||||
<:item title="Result">{@endgame.result}</:item>
|
||||
<:item title="Attempted">{@endgame.times_attempted}</:item>
|
||||
<:item title="Solved">{@endgame.times_solved}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Endgame")
|
||||
|> assign(:endgame, Endgames.get_endgame!(id))}
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,17 @@ defmodule ChesstrainerWeb.Router do
|
||||
live "/chess-test", ChessTestLive
|
||||
end
|
||||
|
||||
scope "/admin", ChesstrainerWeb do
|
||||
pipe_through :browser
|
||||
|
||||
live_session :endgames do
|
||||
live "/endgames", EndgameLive.Index, :index
|
||||
live "/endgames/new", EndgameLive.Form, :new
|
||||
live "/endgames/:id", EndgameLive.Show, :show
|
||||
live "/endgames/:id/edit", EndgameLive.Form, :edit
|
||||
end
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", ChesstrainerWeb do
|
||||
# pipe_through :api
|
||||
|
||||
Reference in New Issue
Block a user