basic admin endgame view and add (#3)

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-06-22 18:44:52 +00:00
parent e955e40930
commit b47fcdffcb
82 changed files with 2031 additions and 2 deletions
+3 -1
View File
@@ -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
+101
View File
@@ -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
+29
View File
@@ -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
+29
View File
@@ -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
+123
View File
@@ -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
+41
View File
@@ -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
+57
View File
@@ -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}}
# Catchall for other status codes
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, status, body}}
# Network / clientside 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
+13
View File
@@ -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
+145
View File
@@ -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
+48
View File
@@ -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
+55
View File
@@ -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
+104
View File
@@ -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
+19
View File
@@ -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