basic admin endgame view and add
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user