From 006a2ca1c589782b480ccda1c595f965dc8622ad Mon Sep 17 00:00:00 2001 From: Owen Rees Date: Mon, 22 Jun 2026 20:43:09 +0200 Subject: [PATCH] basic admin endgame view and add --- assets/css/_board.css | 7 + assets/css/app.css | 1 + lib/chesstrainer/application.ex | 4 +- lib/chesstrainer/cache.ex | 101 +++++++++ lib/chesstrainer/cache/evict.ex | 29 +++ lib/chesstrainer/cache/persist.ex | 29 +++ lib/chesstrainer/endgames.ex | 123 ++++++++++ lib/chesstrainer/endgames/endgame.ex | 41 ++++ lib/chesstrainer/endgames/tablebase.ex | 57 +++++ lib/chesstrainer/endgames/tablebase/cache.ex | 17 ++ lib/chesstrainer/endgames/tablebase/move.ex | 41 ++++ lib/chesstrainer/endgames/tablebase/parser.ex | 106 +++++++++ lib/chesstrainer/fen.ex | 13 ++ lib/chesstrainer/game.ex | 145 ++++++++++++ lib/chesstrainer/ratelimiters.ex | 48 ++++ lib/chesstrainer/ratelimiters/lichess.ex | 55 +++++ lib/chesstrainer/tags.ex | 104 +++++++++ lib/chesstrainer/tags/tag.ex | 19 ++ .../components/board/board_component.ex | 55 +++++ .../components/board/piece_component.ex | 33 +++ .../components/board/square_component.ex | 48 ++++ .../live/endgame_live/form.ex | 212 ++++++++++++++++++ .../live/endgame_live/index.ex | 71 ++++++ .../live/endgame_live/show.ex | 64 ++++++ lib/chesstrainer_web/router.ex | 11 + mix.exs | 3 +- mix.lock | 2 + .../migrations/20251105204020_create_tags.exs | 14 ++ .../20251127164545_create_endgames.exs | 23 ++ .../20251205210539_create_endgames_tags.exs | 12 + priv/repo/seeds.exs | 54 +++++ priv/static/backups/tablebase.det | Bin 0 -> 5464 bytes ...ishop-cb69fc1a80edd0241466c0d47b116db2.svg | 12 + ...op-cb69fc1a80edd0241466c0d47b116db2.svg.gz | Bin 0 -> 623 bytes priv/static/images/pieces/black/bishop.svg | 12 + priv/static/images/pieces/black/bishop.svg.gz | Bin 0 -> 623 bytes .../king-e3451fff6ecf0fcd6de9217ccec6ba84.svg | 12 + ...ng-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz | Bin 0 -> 598 bytes priv/static/images/pieces/black/king.svg | 12 + priv/static/images/pieces/black/king.svg.gz | Bin 0 -> 598 bytes ...night-7b95d1a433d0a64a1e34ed69ca8ad127.svg | 22 ++ ...ht-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz | Bin 0 -> 759 bytes priv/static/images/pieces/black/knight.svg | 22 ++ priv/static/images/pieces/black/knight.svg.gz | Bin 0 -> 759 bytes .../pawn-876f1ece4514c9b19b748e5b985e74f2.svg | 5 + ...wn-876f1ece4514c9b19b748e5b985e74f2.svg.gz | Bin 0 -> 484 bytes priv/static/images/pieces/black/pawn.svg | 5 + priv/static/images/pieces/black/pawn.svg.gz | Bin 0 -> 484 bytes ...queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg | 27 +++ ...en-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz | Bin 0 -> 614 bytes priv/static/images/pieces/black/queen.svg | 27 +++ priv/static/images/pieces/black/queen.svg.gz | Bin 0 -> 614 bytes .../rook-9a79237a3c4acee7893525f1bfc456ca.svg | 39 ++++ ...ok-9a79237a3c4acee7893525f1bfc456ca.svg.gz | Bin 0 -> 538 bytes priv/static/images/pieces/black/rook.svg | 39 ++++ priv/static/images/pieces/black/rook.svg.gz | Bin 0 -> 538 bytes ...ishop-b99f3830dcbf60642c26befe1514ebbc.svg | 12 + ...op-b99f3830dcbf60642c26befe1514ebbc.svg.gz | Bin 0 -> 622 bytes priv/static/images/pieces/white/bishop.svg | 12 + priv/static/images/pieces/white/bishop.svg.gz | Bin 0 -> 622 bytes .../king-27fd3307056d10dae685faf4ca8f4488.svg | 9 + ...ng-27fd3307056d10dae685faf4ca8f4488.svg.gz | Bin 0 -> 366 bytes priv/static/images/pieces/white/king.svg | 9 + priv/static/images/pieces/white/king.svg.gz | Bin 0 -> 366 bytes ...night-b243da7dc2f0791b4b6915ed2091b288.svg | 19 ++ ...ht-b243da7dc2f0791b4b6915ed2091b288.svg.gz | Bin 0 -> 639 bytes priv/static/images/pieces/white/knight.svg | 19 ++ priv/static/images/pieces/white/knight.svg.gz | Bin 0 -> 639 bytes .../pawn-e38a4f66149924b57e4617dbbfe10ef7.svg | 5 + ...wn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz | Bin 0 -> 486 bytes priv/static/images/pieces/white/pawn.svg | 5 + priv/static/images/pieces/white/pawn.svg.gz | Bin 0 -> 486 bytes ...queen-518cd5f8585dbdb35199fa1edf31979f.svg | 15 ++ ...en-518cd5f8585dbdb35199fa1edf31979f.svg.gz | Bin 0 -> 509 bytes priv/static/images/pieces/white/queen.svg | 15 ++ priv/static/images/pieces/white/queen.svg.gz | Bin 0 -> 509 bytes .../rook-0095917f84de3487e19f9d8d46876859.svg | 25 +++ ...ok-0095917f84de3487e19f9d8d46876859.svg.gz | Bin 0 -> 498 bytes priv/static/images/pieces/white/rook.svg | 25 +++ priv/static/images/pieces/white/rook.svg.gz | Bin 0 -> 498 bytes test/chesstrainer/tags_test.exs | 61 +++++ test/support/fixtures/tags_fixtures.ex | 26 +++ 82 files changed, 2031 insertions(+), 2 deletions(-) create mode 100644 assets/css/_board.css create mode 100644 lib/chesstrainer/cache.ex create mode 100644 lib/chesstrainer/cache/evict.ex create mode 100644 lib/chesstrainer/cache/persist.ex create mode 100644 lib/chesstrainer/endgames.ex create mode 100644 lib/chesstrainer/endgames/endgame.ex create mode 100644 lib/chesstrainer/endgames/tablebase.ex create mode 100644 lib/chesstrainer/endgames/tablebase/cache.ex create mode 100644 lib/chesstrainer/endgames/tablebase/move.ex create mode 100644 lib/chesstrainer/endgames/tablebase/parser.ex create mode 100644 lib/chesstrainer/fen.ex create mode 100644 lib/chesstrainer/game.ex create mode 100644 lib/chesstrainer/ratelimiters.ex create mode 100644 lib/chesstrainer/ratelimiters/lichess.ex create mode 100644 lib/chesstrainer/tags.ex create mode 100644 lib/chesstrainer/tags/tag.ex create mode 100644 lib/chesstrainer_web/components/board/board_component.ex create mode 100644 lib/chesstrainer_web/components/board/piece_component.ex create mode 100644 lib/chesstrainer_web/components/board/square_component.ex create mode 100644 lib/chesstrainer_web/live/endgame_live/form.ex create mode 100644 lib/chesstrainer_web/live/endgame_live/index.ex create mode 100644 lib/chesstrainer_web/live/endgame_live/show.ex create mode 100644 priv/repo/migrations/20251105204020_create_tags.exs create mode 100644 priv/repo/migrations/20251127164545_create_endgames.exs create mode 100644 priv/repo/migrations/20251205210539_create_endgames_tags.exs create mode 100644 priv/static/backups/tablebase.det create mode 100644 priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg create mode 100644 priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz create mode 100644 priv/static/images/pieces/black/bishop.svg create mode 100644 priv/static/images/pieces/black/bishop.svg.gz create mode 100644 priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg create mode 100644 priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz create mode 100644 priv/static/images/pieces/black/king.svg create mode 100644 priv/static/images/pieces/black/king.svg.gz create mode 100644 priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg create mode 100644 priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz create mode 100644 priv/static/images/pieces/black/knight.svg create mode 100644 priv/static/images/pieces/black/knight.svg.gz create mode 100644 priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg create mode 100644 priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz create mode 100644 priv/static/images/pieces/black/pawn.svg create mode 100644 priv/static/images/pieces/black/pawn.svg.gz create mode 100644 priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg create mode 100644 priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz create mode 100644 priv/static/images/pieces/black/queen.svg create mode 100644 priv/static/images/pieces/black/queen.svg.gz create mode 100644 priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg create mode 100644 priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz create mode 100644 priv/static/images/pieces/black/rook.svg create mode 100644 priv/static/images/pieces/black/rook.svg.gz create mode 100644 priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg create mode 100644 priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz create mode 100644 priv/static/images/pieces/white/bishop.svg create mode 100644 priv/static/images/pieces/white/bishop.svg.gz create mode 100644 priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg create mode 100644 priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz create mode 100644 priv/static/images/pieces/white/king.svg create mode 100644 priv/static/images/pieces/white/king.svg.gz create mode 100644 priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg create mode 100644 priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz create mode 100644 priv/static/images/pieces/white/knight.svg create mode 100644 priv/static/images/pieces/white/knight.svg.gz create mode 100644 priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg create mode 100644 priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz create mode 100644 priv/static/images/pieces/white/pawn.svg create mode 100644 priv/static/images/pieces/white/pawn.svg.gz create mode 100644 priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg create mode 100644 priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz create mode 100644 priv/static/images/pieces/white/queen.svg create mode 100644 priv/static/images/pieces/white/queen.svg.gz create mode 100644 priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg create mode 100644 priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz create mode 100644 priv/static/images/pieces/white/rook.svg create mode 100644 priv/static/images/pieces/white/rook.svg.gz create mode 100644 test/chesstrainer/tags_test.exs create mode 100644 test/support/fixtures/tags_fixtures.ex diff --git a/assets/css/_board.css b/assets/css/_board.css new file mode 100644 index 0000000..a5c5a60 --- /dev/null +++ b/assets/css/_board.css @@ -0,0 +1,7 @@ +.bg-boardwhite { + background-color: #f2f5f3; /* light square */ +} + +.bg-boardblack { + background-color: #71828f; /* dark square */ +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 3650ca2..0f151d4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -8,6 +8,7 @@ @import "../node_modules/chessground/assets/chessground.brown.css"; @import "../node_modules/chessground/assets/chessground.cburnett.css"; +@import "./_board.css"; @import "./_promotion.css"; @source "../css"; diff --git a/lib/chesstrainer/application.ex b/lib/chesstrainer/application.ex index c79671b..65f5b77 100644 --- a/lib/chesstrainer/application.ex +++ b/lib/chesstrainer/application.ex @@ -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 diff --git a/lib/chesstrainer/cache.ex b/lib/chesstrainer/cache.ex new file mode 100644 index 0000000..d397652 --- /dev/null +++ b/lib/chesstrainer/cache.ex @@ -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 diff --git a/lib/chesstrainer/cache/evict.ex b/lib/chesstrainer/cache/evict.ex new file mode 100644 index 0000000..a6dfd5a --- /dev/null +++ b/lib/chesstrainer/cache/evict.ex @@ -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 diff --git a/lib/chesstrainer/cache/persist.ex b/lib/chesstrainer/cache/persist.ex new file mode 100644 index 0000000..dbed95f --- /dev/null +++ b/lib/chesstrainer/cache/persist.ex @@ -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 diff --git a/lib/chesstrainer/endgames.ex b/lib/chesstrainer/endgames.ex new file mode 100644 index 0000000..510f332 --- /dev/null +++ b/lib/chesstrainer/endgames.ex @@ -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 diff --git a/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex new file mode 100644 index 0000000..65a79d9 --- /dev/null +++ b/lib/chesstrainer/endgames/endgame.ex @@ -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 diff --git a/lib/chesstrainer/endgames/tablebase.ex b/lib/chesstrainer/endgames/tablebase.ex new file mode 100644 index 0000000..d735085 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase.ex @@ -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 diff --git a/lib/chesstrainer/endgames/tablebase/cache.ex b/lib/chesstrainer/endgames/tablebase/cache.ex new file mode 100644 index 0000000..8214ad3 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/cache.ex @@ -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 diff --git a/lib/chesstrainer/endgames/tablebase/move.ex b/lib/chesstrainer/endgames/tablebase/move.ex new file mode 100644 index 0000000..da42760 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/move.ex @@ -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 diff --git a/lib/chesstrainer/endgames/tablebase/parser.ex b/lib/chesstrainer/endgames/tablebase/parser.ex new file mode 100644 index 0000000..8f1af3c --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/parser.ex @@ -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 diff --git a/lib/chesstrainer/fen.ex b/lib/chesstrainer/fen.ex new file mode 100644 index 0000000..b23b003 --- /dev/null +++ b/lib/chesstrainer/fen.ex @@ -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 diff --git a/lib/chesstrainer/game.ex b/lib/chesstrainer/game.ex new file mode 100644 index 0000000..d6ec4b6 --- /dev/null +++ b/lib/chesstrainer/game.ex @@ -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 diff --git a/lib/chesstrainer/ratelimiters.ex b/lib/chesstrainer/ratelimiters.ex new file mode 100644 index 0000000..2fbd3bb --- /dev/null +++ b/lib/chesstrainer/ratelimiters.ex @@ -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 diff --git a/lib/chesstrainer/ratelimiters/lichess.ex b/lib/chesstrainer/ratelimiters/lichess.ex new file mode 100644 index 0000000..a138ee5 --- /dev/null +++ b/lib/chesstrainer/ratelimiters/lichess.ex @@ -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 diff --git a/lib/chesstrainer/tags.ex b/lib/chesstrainer/tags.ex new file mode 100644 index 0000000..42c1efc --- /dev/null +++ b/lib/chesstrainer/tags.ex @@ -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 diff --git a/lib/chesstrainer/tags/tag.ex b/lib/chesstrainer/tags/tag.ex new file mode 100644 index 0000000..93fe811 --- /dev/null +++ b/lib/chesstrainer/tags/tag.ex @@ -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 diff --git a/lib/chesstrainer_web/components/board/board_component.ex b/lib/chesstrainer_web/components/board/board_component.ex new file mode 100644 index 0000000..11483e9 --- /dev/null +++ b/lib/chesstrainer_web/components/board/board_component.ex @@ -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""" +
+
+ <%= 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 %> +
+
+ """ + end +end diff --git a/lib/chesstrainer_web/components/board/piece_component.ex b/lib/chesstrainer_web/components/board/piece_component.ex new file mode 100644 index 0000000..d4c9232 --- /dev/null +++ b/lib/chesstrainer_web/components/board/piece_component.ex @@ -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 %> + + <% 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 diff --git a/lib/chesstrainer_web/components/board/square_component.ex b/lib/chesstrainer_web/components/board/square_component.ex new file mode 100644 index 0000000..f538db8 --- /dev/null +++ b/lib/chesstrainer_web/components/board/square_component.ex @@ -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""" +
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" /> +
+ """ + 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 diff --git a/lib/chesstrainer_web/live/endgame_live/form.ex b/lib/chesstrainer_web/live/endgame_live/form.ex new file mode 100644 index 0000000..e102fe6 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/form.ex @@ -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""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage endgame records in your database. + + + <.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" /> + + +
+ + + + + + +
    + <%= for tag <- @tag_results do %> +
  • + {tag.name} +
  • + <% end %> + + <%= if @tag_query != "" and @tag_results == [] do %> +
  • + Create tag "{@tag_query}" +
  • + <% end %> +
+ + +
+ <%= for tag <- @endgame.tags do %> + + {tag.name} + + + <% end %> +
+
+ + <.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" + /> +
+ <.button phx-disable-with="Saving..." variant="primary">Save Endgame + <.button navigate={return_path(@return_to, @endgame)}>Cancel +
+ +
+ """ + 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 diff --git a/lib/chesstrainer_web/live/endgame_live/index.ex b/lib/chesstrainer_web/live/endgame_live/index.ex new file mode 100644 index 0000000..91a5fc9 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/index.ex @@ -0,0 +1,71 @@ +defmodule ChesstrainerWeb.EndgameLive.Index do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Endgames + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Endgames + <:actions> + <.button variant="primary" navigate={~p"/admin/endgames/new"}> + <.icon name="hero-plus" /> New Endgame + + + + + <.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 :let={{_id, endgame}} label="Color">{endgame.color} + <:col :let={{_id, endgame}} label="Key">{endgame.key} + <:col :let={{_id, endgame}} label="Rating">{endgame.rating} + <:col :let={{_id, endgame}} label="Message">{endgame.message} + <:col :let={{_id, endgame}} label="Notes">{endgame.notes} + <:col :let={{_id, endgame}} label="Result">{endgame.result} + <:col :let={{_id, endgame}} label="Attempted">{endgame.times_attempted} + <:col :let={{_id, endgame}} label="Solved">{endgame.times_solved} + <:action :let={{_id, endgame}}> +
+ <.link navigate={~p"/admin/endgames/#{endgame}"}>Show +
+ <.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit + + <:action :let={{id, endgame}}> + <.link + phx-click={JS.push("delete", value: %{id: endgame.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + 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 diff --git a/lib/chesstrainer_web/live/endgame_live/show.ex b/lib/chesstrainer_web/live/endgame_live/show.ex new file mode 100644 index 0000000..c5d6811 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/show.ex @@ -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""" + + <.header> + Endgame {@endgame.id} + <:subtitle>This is a endgame record from your database. + <:actions> + <.button navigate={~p"/admin/endgames"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit endgame + + + + + <.board game={@game} myself="board" /> + + <.list> + <:item title="Fen">{@endgame.fen} + <:item title="Key">{@endgame.key} + <:item title="Color">{@endgame.color} + <:item title="Rating">{@endgame.rating} + <:item title="Message">{@endgame.message} + <:item title="Notes">{@endgame.notes} + <:item title="Result">{@endgame.result} + <:item title="Attempted">{@endgame.times_attempted} + <:item title="Solved">{@endgame.times_solved} + + + """ + 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 diff --git a/lib/chesstrainer_web/router.ex b/lib/chesstrainer_web/router.ex index 1052c59..59fb058 100644 --- a/lib/chesstrainer_web/router.ex +++ b/lib/chesstrainer_web/router.ex @@ -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 diff --git a/mix.exs b/mix.exs index b8cfcd7..90f407d 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,8 @@ defmodule Chesstrainer.MixProject do {:gettext, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:chex, git: "https://github.com/therealowenrees/chex.git", tag: "v0.2.0-alpha.2"} ] end diff --git a/mix.lock b/mix.lock index aba7a3c..34a27c9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "chex": {:git, "https://github.com/therealowenrees/chex.git", "655b5c3df6c5297689d7872bbce1e86c6c629d26", [tag: "v0.2.0-alpha.2"]}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, @@ -21,6 +22,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, diff --git a/priv/repo/migrations/20251105204020_create_tags.exs b/priv/repo/migrations/20251105204020_create_tags.exs new file mode 100644 index 0000000..9644634 --- /dev/null +++ b/priv/repo/migrations/20251105204020_create_tags.exs @@ -0,0 +1,14 @@ +defmodule Chesstrainer.Repo.Migrations.CreateTags do + use Ecto.Migration + + def change do + create table(:tags) do + add :name, :string + add :category, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:tags, [:name, :category]) + end +end diff --git a/priv/repo/migrations/20251127164545_create_endgames.exs b/priv/repo/migrations/20251127164545_create_endgames.exs new file mode 100644 index 0000000..b100576 --- /dev/null +++ b/priv/repo/migrations/20251127164545_create_endgames.exs @@ -0,0 +1,23 @@ +defmodule Chesstrainer.Repo.Migrations.CreateEndgames do + use Ecto.Migration + + def change do + create table(:endgames, primary_key: false) do + add :id, :binary_id, primary_key: true + add :fen, :string + add :color, :string + add :key, :string + add :message, :text + add :notes, :text + add :result, :string + add :rating, :integer, default: 1500 + add :rating_deviation, :integer, default: 350 + add :times_attempted, :integer, default: 0 + add :times_solved, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:endgames, [:fen]) + end +end diff --git a/priv/repo/migrations/20251205210539_create_endgames_tags.exs b/priv/repo/migrations/20251205210539_create_endgames_tags.exs new file mode 100644 index 0000000..d8b2aa1 --- /dev/null +++ b/priv/repo/migrations/20251205210539_create_endgames_tags.exs @@ -0,0 +1,12 @@ +defmodule Chesstrainer.Repo.Migrations.CreateEndgamesTags do + use Ecto.Migration + + def change do + create table(:endgames_tags, primary_key: false) do + add :endgame_id, references(:endgames, type: :binary_id, on_delete: :delete_all) + add :tag_id, references(:tags, on_delete: :delete_all) + end + + create unique_index(:endgames_tags, [:endgame_id, :tag_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5b9b28b..179b8af 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,57 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +alias Chesstrainer.Repo +alias Chesstrainer.Endgames.Endgame +alias Chesstrainer.Tags.Tag + +now = DateTime.utc_now() |> DateTime.truncate(:second) + +IO.puts("🌱 Seeding basic endgames into DB...") + +Repo.insert_all(Endgame, [ + %{ + id: Ecto.UUID.generate(), + fen: "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1", + color: :white, + key: "KR v K", + message: "Rook vs king endgame", + notes: "Basic rook endgame", + result: :win, + inserted_at: now, + updated_at: now + }, + %{ + id: Ecto.UUID.generate(), + fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0", + color: :black, + key: "KPPP v KPP", + message: "3 pawns vs 2 pawns", + notes: "https://www.chessgames.com/perl/chessgame?gid=1017147", + result: :win, + inserted_at: now, + updated_at: now + } +]) + +IO.puts("✅ Done seeding endgames.") + +IO.puts("🌱 Seeding endgame tags into DB...") + +Repo.insert_all(Tag, [ + %{ + name: "mate in 2", + category: :endgame, + inserted_at: now, + updated_at: now + }, + %{ + name: "mate in 3", + category: :endgame, + inserted_at: now, + updated_at: now + } +]) + +IO.puts("✅ Done seeding endgame tags.") diff --git a/priv/static/backups/tablebase.det b/priv/static/backups/tablebase.det new file mode 100644 index 0000000000000000000000000000000000000000..c1ae47e3b05f8128913ddbd12be673bebf7cc413 GIT binary patch literal 5464 zcmZQz5Vc@fefB*A0|O%zb0U~vF`zIAFfuT~7!aDSiJR2M?+wQhXC(r{vY8fVAT7gAut*OqaiRF0;3@?8UmvsFd71*Aut*O dqaiRF0wW>>1ehV?rV7xpNFE5^z!K + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..473a420fd38f81fca5255c9aa901243142761607 GIT binary patch literal 623 zcmV-#0+9V5iwFP!0000019engZ__{!d`IFxEY}y1lKYGu$Bn5VEfrD%m1sdcn%IpE z?m4or4dvIf_u(|O#Zi2lx!Kv7Q|$T4@0$kh%C4_loe@Dffl{yAqSl*?TwQ(`Cj@$v z>mqMjU1mhL4Zp{3Xewy5N19rf>wKGb?X52UL4UTjegxmtrtF#;mvpiRi@dM$uFLO( zy8u6!Az->(_t$NAlMz=oxhY@s5vTDRpZGAd3;M|TlOTibAGgLF4;rYl%}oVGMpiHz zsTdX@Bvn{Olq(Q3Dg?rGqCiR-jno7gGNQ>Wpi0tM1h$I3f?w5?#*t-36R?7b-ASmD zSkl?l?yJbvGuM&O$TGa%ltyZpgN!&#pE^!(^=Cg&X!B; zZhr`S?nneOrJXo+*->2XE5-eu;uhfn;o)xaktO>M8ShuzQ6uRT6e7Z@+H`{ZbYeh- z^gOZe94hqXw5>lo%dfV{JMq5%6G}#qz&nt3E`G5OKOp(&In;6=bmnX`R0oF?OfB~j zSHaLS9L6dHj8J&#YiVt+>{O9h40ql8=;052_-v#{*Y0}yxVZ=HI2iQy=Jw@*>tC)O JB1{4W005^sH@N@+ literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/bishop.svg b/priv/static/images/pieces/black/bishop.svg new file mode 100644 index 0000000..b6a8006 --- /dev/null +++ b/priv/static/images/pieces/black/bishop.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/bishop.svg.gz b/priv/static/images/pieces/black/bishop.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..473a420fd38f81fca5255c9aa901243142761607 GIT binary patch literal 623 zcmV-#0+9V5iwFP!0000019engZ__{!d`IFxEY}y1lKYGu$Bn5VEfrD%m1sdcn%IpE z?m4or4dvIf_u(|O#Zi2lx!Kv7Q|$T4@0$kh%C4_loe@Dffl{yAqSl*?TwQ(`Cj@$v z>mqMjU1mhL4Zp{3Xewy5N19rf>wKGb?X52UL4UTjegxmtrtF#;mvpiRi@dM$uFLO( zy8u6!Az->(_t$NAlMz=oxhY@s5vTDRpZGAd3;M|TlOTibAGgLF4;rYl%}oVGMpiHz zsTdX@Bvn{Olq(Q3Dg?rGqCiR-jno7gGNQ>Wpi0tM1h$I3f?w5?#*t-36R?7b-ASmD zSkl?l?yJbvGuM&O$TGa%ltyZpgN!&#pE^!(^=Cg&X!B; zZhr`S?nneOrJXo+*->2XE5-eu;uhfn;o)xaktO>M8ShuzQ6uRT6e7Z@+H`{ZbYeh- z^gOZe94hqXw5>lo%dfV{JMq5%6G}#qz&nt3E`G5OKOp(&In;6=bmnX`R0oF?OfB~j zSHaLS9L6dHj8J&#YiVt+>{O9h40ql8=;052_-v#{*Y0}yxVZ=HI2iQy=Jw@*>tC)O JB1{4W005^sH@N@+ literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg new file mode 100644 index 0000000..ba2ac9f --- /dev/null +++ b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..3e5e17adc0aae1702c546b1f54a4e238756c8436 GIT binary patch literal 598 zcmV-c0;&BUiwFP!000001Eo~kj+-zLec!Ke-B;oZ<`xpNQj_hfQrfQ6Y^y%fz=B)W zL_m_}?`s>IWV1ifeTXmozgQ`~4p68LFEC-#6)|%7iFjY*$n{GLh?9NlEM?Os#r)i6QJBty-C-+JH*s{H`^+Y^Ib0LESt!r zrv8=3Z;5{F_eeL2De{Mtii>=mZsMliifnRn__OBX<>@o;@}}a_#NLpfrEQruO?oic z%<&POrXh)J(yjzq0zU%RMZO~y3m91T9|63;@(P6kn}#!ifXkw)c;JTw;>+D!AIt~O z4ILUHX?=ymL;7y;T~B$gLFuYH(tC7lV~*Y&(8yG?)qDGrktK0Hxw9es*mm7jqOYk@ z)ry5Z2#+95O38^ON;liH0SfaG)m$`3Wo1Q|FJNy>oV7I4S3@z5eJP3Q`{n>5>=DeGDSuZ z??k2m1%vMmLPbibNGAqp91i8YK`YkxHTG}pp7e8l9Z0_pa$s^9 + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/king.svg.gz b/priv/static/images/pieces/black/king.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..3e5e17adc0aae1702c546b1f54a4e238756c8436 GIT binary patch literal 598 zcmV-c0;&BUiwFP!000001Eo~kj+-zLec!Ke-B;oZ<`xpNQj_hfQrfQ6Y^y%fz=B)W zL_m_}?`s>IWV1ifeTXmozgQ`~4p68LFEC-#6)|%7iFjY*$n{GLh?9NlEM?Os#r)i6QJBty-C-+JH*s{H`^+Y^Ib0LESt!r zrv8=3Z;5{F_eeL2De{Mtii>=mZsMliifnRn__OBX<>@o;@}}a_#NLpfrEQruO?oic z%<&POrXh)J(yjzq0zU%RMZO~y3m91T9|63;@(P6kn}#!ifXkw)c;JTw;>+D!AIt~O z4ILUHX?=ymL;7y;T~B$gLFuYH(tC7lV~*Y&(8yG?)qDGrktK0Hxw9es*mm7jqOYk@ z)ry5Z2#+95O38^ON;liH0SfaG)m$`3Wo1Q|FJNy>oV7I4S3@z5eJP3Q`{n>5>=DeGDSuZ z??k2m1%vMmLPbibNGAqp91i8YK`YkxHTG}pp7e8l9Z0_pa$s^9 + + + + + + + + + + diff --git a/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..1e43ee90b03c55245f4f031be84752f8f6a45f36 GIT binary patch literal 759 zcmV9xzDzD11P$><*Z8cay}MUmmdbP zfK`)}X;M^WHWp()64Qt3iER zWW($(E2}g;CjYwnl?`9Z_P3D-ZPKAayVVqVnaz^ru&!>)^mtGCv&zdqwKsW_)kRKZ z7;OF1WVJ}@I(g`^_39lu0ZpBhtLv)18H<)Jk|sNno<#GetCuQXCe5N_0n)Mf1X_Da z!UT*)>nQ~$W*QUqjDsmC%XHFG37Sqa1Z~JhYy$O7Y&-X)6I)q;|91=lXPgj4N~Ewknc4|R+1@=l5!LR zwPB6nx*ZdX&P5!F5{0BBLb0hcn{%>}493o*3ne%2nE~{~Jybx|kwJ8G}|HmrYL;_3zZ4&(?RxFKOv`2V6<}2dWGh%fXAS z$KzoT*X?fyqL=>#LQ!9Ksnj-A+u*|&y~hs2-sImTO`YE#NsPmgM&Eh^K0481=!`e8 zs3Ka4=PH1v*7DO~Ko3fSqBX+Uc0x$Q!cQ1sL^cL!L}`a;A_CX1Tue8-e*s%)gOYa8 zw!GtMCSKca6`3UtIkbs*J$6FBrMx~dx&Tawq4qQls^!p}Gs2K(n-(XIJzgRjDq6CT p;Y!WVD#LE1r~5TM!hdzD{VHwRH1g;Cz2?s`(ckJb1LIT%007|IXj}jQ literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/knight.svg b/priv/static/images/pieces/black/knight.svg new file mode 100644 index 0000000..203c6fe --- /dev/null +++ b/priv/static/images/pieces/black/knight.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/knight.svg.gz b/priv/static/images/pieces/black/knight.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..1e43ee90b03c55245f4f031be84752f8f6a45f36 GIT binary patch literal 759 zcmV9xzDzD11P$><*Z8cay}MUmmdbP zfK`)}X;M^WHWp()64Qt3iER zWW($(E2}g;CjYwnl?`9Z_P3D-ZPKAayVVqVnaz^ru&!>)^mtGCv&zdqwKsW_)kRKZ z7;OF1WVJ}@I(g`^_39lu0ZpBhtLv)18H<)Jk|sNno<#GetCuQXCe5N_0n)Mf1X_Da z!UT*)>nQ~$W*QUqjDsmC%XHFG37Sqa1Z~JhYy$O7Y&-X)6I)q;|91=lXPgj4N~Ewknc4|R+1@=l5!LR zwPB6nx*ZdX&P5!F5{0BBLb0hcn{%>}493o*3ne%2nE~{~Jybx|kwJ8G}|HmrYL;_3zZ4&(?RxFKOv`2V6<}2dWGh%fXAS z$KzoT*X?fyqL=>#LQ!9Ksnj-A+u*|&y~hs2-sImTO`YE#NsPmgM&Eh^K0481=!`e8 zs3Ka4=PH1v*7DO~Ko3fSqBX+Uc0x$Q!cQ1sL^cL!L}`a;A_CX1Tue8-e*s%)gOYa8 zw!GtMCSKca6`3UtIkbs*J$6FBrMx~dx&Tawq4qQls^!p}Gs2K(n-(XIJzgRjDq6CT p;Y!WVD#LE1r~5TM!hdzD{VHwRH1g;Cz2?s`(ckJb1LIT%007|IXj}jQ literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg new file mode 100644 index 0000000..b534de8 --- /dev/null +++ b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..2eb76801a8b544106ca6622e08ffcdf2e5a9a8c4 GIT binary patch literal 484 zcmV}KRh|16DJ5Y+Hw15pHiYZA2B!)_s6=63Zs9<>kf~g{)5~D1qis>Ad zATy>ll{1!toKcyxvWogAu^Wr|{Fw2lHesx)DsAW5YT zQ=Y@$U#P5_P|NKpc$G$C{chBwM zuTf&~Kiij*Q=Y`zDskis(p%SV>U}x*N7wv?{_xh7J3F<*1&aVyOQZ0fPAhoL@( a?(GGB@kcm5uJCpiSHA%SPgSV?0ssIFqxDAs literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/pawn.svg b/priv/static/images/pieces/black/pawn.svg new file mode 100644 index 0000000..b534de8 --- /dev/null +++ b/priv/static/images/pieces/black/pawn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/black/pawn.svg.gz b/priv/static/images/pieces/black/pawn.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..2eb76801a8b544106ca6622e08ffcdf2e5a9a8c4 GIT binary patch literal 484 zcmV}KRh|16DJ5Y+Hw15pHiYZA2B!)_s6=63Zs9<>kf~g{)5~D1qis>Ad zATy>ll{1!toKcyxvWogAu^Wr|{Fw2lHesx)DsAW5YT zQ=Y@$U#P5_P|NKpc$G$C{chBwM zuTf&~Kiij*Q=Y`zDskis(p%SV>U}x*N7wv?{_xh7J3F<*1&aVyOQZ0fPAhoL@( a?(GGB@kcm5uJCpiSHA%SPgSV?0ssIFqxDAs literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg new file mode 100644 index 0000000..e557734 --- /dev/null +++ b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..3cdc4a6f88e4034ce6ba2a9c4c6dd055865675ba GIT binary patch literal 614 zcmV-s0-60EiwFP!000001BFygbDJ;_z57>e)hi&agg>y2Gp(J;bS7yt)3lu)UBEy~ zz=Ls|VCY8#p{I{gVWo-ECyV7VTb z#WoKo9p~>N&1)}(p*R$2y{r-leRc<)B?mAE96EhVx=tUIXvTqFxdk<%6;$LQ8!c%@ z;S4Pljz_la?{|_dHCB*?Uh75IQF}DHHN&7KH40vt5!U2?l+mwkRZY4zVZdjHi9FUc z0g!q+Rf-K-iwkJcO#ea9Tt~F^GSXm4GiwIzBtw{K{Wl^3>p?0@kY5;fo9HaNMhe5M z2{f5;+Q!qdYKSsQ>{{A>aKRq6`&&~EiTU+yrgwl^ffD-EC{NFv82y#;o+=GWORrLO z23k29{SvJXI7rJNiy(OR*?>XtLd_}}ls=OuG3eGH?*c|QLya0;Kq>q`lk(0<>K4V) zoeCd7Q5mStK<5I=HQs~fGL4keQ?b7}$5-p1?b=xJ1%j3NCai`Y3!~HN%0ewmdjFUY z;kqdl*4J%spMjb_aNj1a8vmJX<{}JF)T;5fDf8I8OL~~isS05B;h AjQ{`u literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/queen.svg b/priv/static/images/pieces/black/queen.svg new file mode 100644 index 0000000..e557734 --- /dev/null +++ b/priv/static/images/pieces/black/queen.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/queen.svg.gz b/priv/static/images/pieces/black/queen.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..3cdc4a6f88e4034ce6ba2a9c4c6dd055865675ba GIT binary patch literal 614 zcmV-s0-60EiwFP!000001BFygbDJ;_z57>e)hi&agg>y2Gp(J;bS7yt)3lu)UBEy~ zz=Ls|VCY8#p{I{gVWo-ECyV7VTb z#WoKo9p~>N&1)}(p*R$2y{r-leRc<)B?mAE96EhVx=tUIXvTqFxdk<%6;$LQ8!c%@ z;S4Pljz_la?{|_dHCB*?Uh75IQF}DHHN&7KH40vt5!U2?l+mwkRZY4zVZdjHi9FUc z0g!q+Rf-K-iwkJcO#ea9Tt~F^GSXm4GiwIzBtw{K{Wl^3>p?0@kY5;fo9HaNMhe5M z2{f5;+Q!qdYKSsQ>{{A>aKRq6`&&~EiTU+yrgwl^ffD-EC{NFv82y#;o+=GWORrLO z23k29{SvJXI7rJNiy(OR*?>XtLd_}}ls=OuG3eGH?*c|QLya0;Kq>q`lk(0<>K4V) zoeCd7Q5mStK<5I=HQs~fGL4keQ?b7}$5-p1?b=xJ1%j3NCai`Y3!~HN%0ewmdjFUY z;kqdl*4J%spMjb_aNj1a8vmJX<{}JF)T;5fDf8I8OL~~isS05B;h AjQ{`u literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg new file mode 100644 index 0000000..4eec43c --- /dev/null +++ b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..2196985561609d9b464184cc2ddd77b27d4c88b9 GIT binary patch literal 538 zcmV+#0_FW5iwFP!000001HDyIZ__Xke&4S+ycdw#I8K_1q)jK$jtObNBsvg}rg0NP zYFCciuKao&Cr#Q2(4<2B@_jk`?z^+^l=02eDu*po1(za5#BmWYv5*NDX^bA{Z~Lzi z6q<^J=2Ea235jkdSK}A6kJI_rhg&GNDLg#BxqmkW)W`UXKgD=9pTVckcR(D1@$Cl$ zDARf!VZ7VzoSpATmEt=^*BM_F*c`;=LF0r?FhZO}C&);Ykxg=LB@ERY0D>90OQouvCmBja!nUp?;|pTCC~Hd+O@0t>UVbgYLl~wZJCfs zGBP2Q{J~n6>bq}cBjN;&CFg=I=sHqzBa+dP@w?>W)VkuDshk^#291A0i;OBo_mMF7 z-k96$5NJh3v6N~RBTI9t*-N+QI{qtLv8j1Yb!Hhr5~KGJ_WTg;!5_9bG#EAz!cRc8 zM}1wLg1&9EHsbgX3dD1Qp6{7xWYF_MCn#k|!`j`Noh?_TUSb*6_gg&Jhi8j4l^8~b zhniZ}?ppRNxo%6?F8F&AwIDIlgHm`VV$^kypQK6x?bAV9o&<#ecp2zK~nd$Wfzq04%S7bPy7FZ c(#Ku@pJc~4H9wy6zcRV{1qj-52^t3g0Ji`LuK)l5 literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/black/rook.svg b/priv/static/images/pieces/black/rook.svg new file mode 100644 index 0000000..4eec43c --- /dev/null +++ b/priv/static/images/pieces/black/rook.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/rook.svg.gz b/priv/static/images/pieces/black/rook.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..2196985561609d9b464184cc2ddd77b27d4c88b9 GIT binary patch literal 538 zcmV+#0_FW5iwFP!000001HDyIZ__Xke&4S+ycdw#I8K_1q)jK$jtObNBsvg}rg0NP zYFCciuKao&Cr#Q2(4<2B@_jk`?z^+^l=02eDu*po1(za5#BmWYv5*NDX^bA{Z~Lzi z6q<^J=2Ea235jkdSK}A6kJI_rhg&GNDLg#BxqmkW)W`UXKgD=9pTVckcR(D1@$Cl$ zDARf!VZ7VzoSpATmEt=^*BM_F*c`;=LF0r?FhZO}C&);Ykxg=LB@ERY0D>90OQouvCmBja!nUp?;|pTCC~Hd+O@0t>UVbgYLl~wZJCfs zGBP2Q{J~n6>bq}cBjN;&CFg=I=sHqzBa+dP@w?>W)VkuDshk^#291A0i;OBo_mMF7 z-k96$5NJh3v6N~RBTI9t*-N+QI{qtLv8j1Yb!Hhr5~KGJ_WTg;!5_9bG#EAz!cRc8 zM}1wLg1&9EHsbgX3dD1Qp6{7xWYF_MCn#k|!`j`Noh?_TUSb*6_gg&Jhi8j4l^8~b zhniZ}?ppRNxo%6?F8F&AwIDIlgHm`VV$^kypQK6x?bAV9o&<#ecp2zK~nd$Wfzq04%S7bPy7FZ c(#Ku@pJc~4H9wy6zcRV{1qj-52^t3g0Ji`LuK)l5 literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg new file mode 100644 index 0000000..18f034d --- /dev/null +++ b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..0800e6ee3086f32f5bcaa27a3048cade670c3930 GIT binary patch literal 622 zcmV-!0+Ib6iwFP!0000019engZ__{!d`IFxEY}y1lKYGu$Bn5VEfrD%m1sdcn%IpE z?m4or4dvIf_u(|4)K+|(x!Ktn%l7>2_gw=IW!KlO&WNC#K&jVlQR_`cZmvI!69T=- zb&)r%E;FKAa`yJ+i}|bb&x`BtR~OJfY~bqV{qo}i$e6KjYQfm~^*Ma`dI^FG#x6b) zAeAxOl(F4zM|X<0-G*Iu`L?RpJwqb1L~LRxCIl@^LF^1GjrMe(9qE^xbIT`(?gfEe zU6?8(lZZf7)|<+>8sz}y8;tVUV2XB|uWR#|ic`3)n zo38y;rYGDT!~d}xnhF~2k*3z=I^U*Ud#{Us(4TFs|AX&pQ+7>_OFG$uMc!9=*X57F zU4Wm=5HMY?``fm=%ZMwR+?22Rh|~CuPkfl!1wFZS$Fo5O+duA&IUO`mWt*D{ij1sa zHc~MxKuD^vj3`$iW>g4-=|q8)G#aT1GGs)PSwNMfu?TDxdj-F$DUBn`iY8zM6T6d8 zC9$NlsohtRt7onwp^;^Hy(x{e!I5kJ`}$23mDz}S26W1TIR z*j@e*cI=i2WJ)`6>awG_+*gYGJ;g1;1H!}A;v-A;9WvgpxT8kWDJVpQQ?=;?_vyrd z3h8-b-#Jw1%V}GGc9vgllXv2MKNCtukia{Tb}s&~4^NPMbPToJC!IOl4AsF+3Z|C( zh^t^|8E(cZ1dLF4>1%0iuIyBiSPXaF_vq + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/bishop.svg.gz b/priv/static/images/pieces/white/bishop.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..0800e6ee3086f32f5bcaa27a3048cade670c3930 GIT binary patch literal 622 zcmV-!0+Ib6iwFP!0000019engZ__{!d`IFxEY}y1lKYGu$Bn5VEfrD%m1sdcn%IpE z?m4or4dvIf_u(|4)K+|(x!Ktn%l7>2_gw=IW!KlO&WNC#K&jVlQR_`cZmvI!69T=- zb&)r%E;FKAa`yJ+i}|bb&x`BtR~OJfY~bqV{qo}i$e6KjYQfm~^*Ma`dI^FG#x6b) zAeAxOl(F4zM|X<0-G*Iu`L?RpJwqb1L~LRxCIl@^LF^1GjrMe(9qE^xbIT`(?gfEe zU6?8(lZZf7)|<+>8sz}y8;tVUV2XB|uWR#|ic`3)n zo38y;rYGDT!~d}xnhF~2k*3z=I^U*Ud#{Us(4TFs|AX&pQ+7>_OFG$uMc!9=*X57F zU4Wm=5HMY?``fm=%ZMwR+?22Rh|~CuPkfl!1wFZS$Fo5O+duA&IUO`mWt*D{ij1sa zHc~MxKuD^vj3`$iW>g4-=|q8)G#aT1GGs)PSwNMfu?TDxdj-F$DUBn`iY8zM6T6d8 zC9$NlsohtRt7onwp^;^Hy(x{e!I5kJ`}$23mDz}S26W1TIR z*j@e*cI=i2WJ)`6>awG_+*gYGJ;g1;1H!}A;v-A;9WvgpxT8kWDJVpQQ?=;?_vyrd z3h8-b-#Jw1%V}GGc9vgllXv2MKNCtukia{Tb}s&~4^NPMbPToJC!IOl4AsF+3Z|C( zh^t^|8E(cZ1dLF4>1%0iuIyBiSPXaF_vq + + + + + + + + \ No newline at end of file diff --git a/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..5cce64ac21d224599e95754e1bb722c521d7ed3e GIT binary patch literal 366 zcmV-!0g?V6iwFP!000001C^3ZZ-X!lhVT6qQF=~D2p`=9?QOT-w%!>8$VQV2QrOR* z4V|{O+hL02*pBV@v6O80aqP&b`@uMyQ^^G-+Lo>|w#n(s^Ap=pGWfz)Mdz%}sdaR> zU1h`A5a_f+PFwF!k3ld^6Q2}!eG`O2hyW@mnM~!|oJKJvtu{^TXMejQBx^`*x()|g zO=q9==MEI~sI99il}~!6Ao-#Sx~Iao_gzQh zbx%o^(|s87m`KSJbx8K1Ae%O(!O!M}dtKM{J=N>k`(L1L$A|F{@tCFHDkcSwOF;ya z4BnVRJ1|A&0By-67Q(^O%k?)Csz_8(X&K{%`$QK977`;%hX;n4WD#c(OF&F2R758s z(IHGn49XcQ#cViN!z + + + + + + + + \ No newline at end of file diff --git a/priv/static/images/pieces/white/king.svg.gz b/priv/static/images/pieces/white/king.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..5cce64ac21d224599e95754e1bb722c521d7ed3e GIT binary patch literal 366 zcmV-!0g?V6iwFP!000001C^3ZZ-X!lhVT6qQF=~D2p`=9?QOT-w%!>8$VQV2QrOR* z4V|{O+hL02*pBV@v6O80aqP&b`@uMyQ^^G-+Lo>|w#n(s^Ap=pGWfz)Mdz%}sdaR> zU1h`A5a_f+PFwF!k3ld^6Q2}!eG`O2hyW@mnM~!|oJKJvtu{^TXMejQBx^`*x()|g zO=q9==MEI~sI99il}~!6Ao-#Sx~Iao_gzQh zbx%o^(|s87m`KSJbx8K1Ae%O(!O!M}dtKM{J=N>k`(L1L$A|F{@tCFHDkcSwOF;ya z4BnVRJ1|A&0By-67Q(^O%k?)Csz_8(X&K{%`$QK977`;%hX;n4WD#c(OF&F2R758s z(IHGn49XcQ#cViN!z + + + + + + + + + diff --git a/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..dd8df6ef425d937d79573ff3cf5a687b121e3244 GIT binary patch literal 639 zcmV-_0)YJ=iwFP!000001FckTZ`3dh{*J_dFxi(OwMm@xE!~v~x-lUQn8XI+V|DGW zg#Md&7;?#=Xy$ERHH(B@UA}cA z2K|sVc~-Ygk+7y^$FH70o4q*sm|lLpIEDUp0T)+q&flfLTp_;bREU$y6ZrJ`43MJ` zrym%wau`;z5bO1tuQhMGg*fZ7Rawn@K^ukLNJ)ZI9J2s7x`#VovHN`wz#a(;TRnbt(bW~H*xTjj zSzl&dm)&o$ZQKv&7z|z3^w({-OxS4F*-#uxM{@mgnwKVCWkWfc0C~dRgHjGkNI@&^ zJBXkot0U5mHgFE;Tb+(tf+9&9L225k-#C>9w3rI5yOp7KFMVOsJMrez)fJ|%2q~cHC-h%RDvsQ zJ=)G~tn#@rp{+!X0&tpn~o^loYN@(8}G)Rq)*~C*h$70Xfm)^_6Bwt zKlX!o`WgHc2qaz}TH&tMNbvr^CgTCaqnp3XhOW9hlspOoy86E3S}`pR4~%wPpJPa$ Z#J>zM6ASwzvi}* + + + + + + + + + diff --git a/priv/static/images/pieces/white/knight.svg.gz b/priv/static/images/pieces/white/knight.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..dd8df6ef425d937d79573ff3cf5a687b121e3244 GIT binary patch literal 639 zcmV-_0)YJ=iwFP!000001FckTZ`3dh{*J_dFxi(OwMm@xE!~v~x-lUQn8XI+V|DGW zg#Md&7;?#=Xy$ERHH(B@UA}cA z2K|sVc~-Ygk+7y^$FH70o4q*sm|lLpIEDUp0T)+q&flfLTp_;bREU$y6ZrJ`43MJ` zrym%wau`;z5bO1tuQhMGg*fZ7Rawn@K^ukLNJ)ZI9J2s7x`#VovHN`wz#a(;TRnbt(bW~H*xTjj zSzl&dm)&o$ZQKv&7z|z3^w({-OxS4F*-#uxM{@mgnwKVCWkWfc0C~dRgHjGkNI@&^ zJBXkot0U5mHgFE;Tb+(tf+9&9L225k-#C>9w3rI5yOp7KFMVOsJMrez)fJ|%2q~cHC-h%RDvsQ zJ=)G~tn#@rp{+!X0&tpn~o^loYN@(8}G)Rq)*~C*h$70Xfm)^_6Bwt zKlX!o`WgHc2qaz}TH&tMNbvr^CgTCaqnp3XhOW9hlspOoy86E3S}`pR4~%wPpJPa$ Z#J>zM6ASwzvi}* + + + + diff --git a/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..ede930b4b5a745ea36d097dbb86ba359c7d6e613 GIT binary patch literal 486 zcmV)+3e!)K1d^MH`?_iyj3_vDnI_ zAt`a4ukVs^-3Q8pxI3Dioh3QH{;})fsU1e^T}31l1X{Q8jdk0K+^^pxIe~GiT~qho zwH0xmTrV!>uT~$I>#uh=Fg|VJ?*8@rcS|4%=U?=a^VNCDx{8>T zK-b!BH^q2y0Wja!QwL2&cAykXX#pEZ6jPE4NDP%ME5dF-P{Hy71XD#oB}Q3J71KE^ zL1s*ADrYPOIioUXX%PY?QB5F8Xrvd0YL**N%t)FB3=7kcsEi3LkW@>S<}oNx>Mdx4 z9BodxLa;cI)hx!dAfs|7u$hVC2y4d`rM!X%+xO+=LtHOe^3UI7c + + + + diff --git a/priv/static/images/pieces/white/pawn.svg.gz b/priv/static/images/pieces/white/pawn.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..ede930b4b5a745ea36d097dbb86ba359c7d6e613 GIT binary patch literal 486 zcmV)+3e!)K1d^MH`?_iyj3_vDnI_ zAt`a4ukVs^-3Q8pxI3Dioh3QH{;})fsU1e^T}31l1X{Q8jdk0K+^^pxIe~GiT~qho zwH0xmTrV!>uT~$I>#uh=Fg|VJ?*8@rcS|4%=U?=a^VNCDx{8>T zK-b!BH^q2y0Wja!QwL2&cAykXX#pEZ6jPE4NDP%ME5dF-P{Hy71XD#oB}Q3J71KE^ zL1s*ADrYPOIioUXX%PY?QB5F8Xrvd0YL**N%t)FB3=7kcsEi3LkW@>S<}oNx>Mdx4 z9BodxLa;cI)hx!dAfs|7u$hVC2y4d`rM!X%+xO+=LtHOe^3UI7c + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..e5198601ce6d88414f6c87927c88f896e67efd46 GIT binary patch literal 509 zcmVe$TJCU0;cfF%ZZ?Dvfm2q^_;BbkiOqpuomV z1gy0C^*bAAGAYe?@SX2IfA`&CuP&c|Rq&KGZCR@b1+x({mDFje@(3TcALju=+wD}k zt7?@+sA{}i&sOg?ck%Z7{S~xNIov;dxcL+V&N=@kW6n3*4Se}}4T1^IuWk{r=(_!q z^S{JPogi!y0B0l5a!Ax>mMu(V5&N@!*hr`qVmamTi84aZ~c0--O{u87d} z5Q?nKi_ZA#8Ne#1K3*zn;jyf$<;A1<-nU&-|IC&bw)w{G+|Voq^QJ&ms_aKys%2CE zR;eK*%Kfe@AdT=dgqGt&3}V4N%W;_p(jKj^GdCau$_Pmj@`dFXYqNzVY!;I5c+*Mp z9gVfg6X78$TqY;9a0dqeKx!2HLH?FBG6B$W&sb{BlD1Z?Yf0Kp9$8Cj%6LFaU0n!E z`r}GeI=mRe!I2#YAPE4@t0&zlJ9*NZ7<9xRGd}2ALqRDg5pt9zUxn(#Al~4q9X;e= z9CbWYHbXo7jyI=sGzo%+=D&qg3H;~ED0%>=7!LCaEUl6R40ded3`SJ%JJK01D4w^yL{)!5Nek|7VinNl7c7)9ciaZ$0}9w@sfBKLh{(r}gs9 literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/white/queen.svg b/priv/static/images/pieces/white/queen.svg new file mode 100644 index 0000000..8df7c8f --- /dev/null +++ b/priv/static/images/pieces/white/queen.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/queen.svg.gz b/priv/static/images/pieces/white/queen.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..e5198601ce6d88414f6c87927c88f896e67efd46 GIT binary patch literal 509 zcmVe$TJCU0;cfF%ZZ?Dvfm2q^_;BbkiOqpuomV z1gy0C^*bAAGAYe?@SX2IfA`&CuP&c|Rq&KGZCR@b1+x({mDFje@(3TcALju=+wD}k zt7?@+sA{}i&sOg?ck%Z7{S~xNIov;dxcL+V&N=@kW6n3*4Se}}4T1^IuWk{r=(_!q z^S{JPogi!y0B0l5a!Ax>mMu(V5&N@!*hr`qVmamTi84aZ~c0--O{u87d} z5Q?nKi_ZA#8Ne#1K3*zn;jyf$<;A1<-nU&-|IC&bw)w{G+|Voq^QJ&ms_aKys%2CE zR;eK*%Kfe@AdT=dgqGt&3}V4N%W;_p(jKj^GdCau$_Pmj@`dFXYqNzVY!;I5c+*Mp z9gVfg6X78$TqY;9a0dqeKx!2HLH?FBG6B$W&sb{BlD1Z?Yf0Kp9$8Cj%6LFaU0n!E z`r}GeI=mRe!I2#YAPE4@t0&zlJ9*NZ7<9xRGd}2ALqRDg5pt9zUxn(#Al~4q9X;e= z9CbWYHbXo7jyI=sGzo%+=D&qg3H;~ED0%>=7!LCaEUl6R40ded3`SJ%JJK01D4w^yL{)!5Nek|7VinNl7c7)9ciaZ$0}9w@sfBKLh{(r}gs9 literal 0 HcmV?d00001 diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg new file mode 100644 index 0000000..ac03578 --- /dev/null +++ b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..30b59b76d2afdba488a703b7cd7cd4215c7c4a94 GIT binary patch literal 498 zcmV`?4(Ib*``V0m+yS>efQnjm+8gNbqPDBDlSEei0dI>Vkt8&@)X@IuAJ8h zDosU3ODR~2ghUr_oYNR-Pnc>V49m!_NA#pZs?NheL^O%;$@kft(w*DoRhDP+LkO~J0@h7 zO$>#SKUi|+HLKe@P2PmKQST|aU`x75l-!DJa^(Cjx%kt&=9;OJ8%V-FKBHAZm7@DZ znAZ31K^+3EsHj#_ty9#PCDrVuH}c%zRa>$tdP8;5ngCgf-a|YJVz_}|JmA=1+(Qh% z0CgdUj-Ax>ZL77(j~`4z{Fct5Z?gmgcK-4I$U{aXw0vY_iy=`5gc$U_9p^Yu?x6g8O`d??&xi ok-=&;Jmn(pZC^8F$FKOW@p(Y%6zBF&U + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/rook.svg.gz b/priv/static/images/pieces/white/rook.svg.gz new file mode 100644 index 0000000000000000000000000000000000000000..30b59b76d2afdba488a703b7cd7cd4215c7c4a94 GIT binary patch literal 498 zcmV`?4(Ib*``V0m+yS>efQnjm+8gNbqPDBDlSEei0dI>Vkt8&@)X@IuAJ8h zDosU3ODR~2ghUr_oYNR-Pnc>V49m!_NA#pZs?NheL^O%;$@kft(w*DoRhDP+LkO~J0@h7 zO$>#SKUi|+HLKe@P2PmKQST|aU`x75l-!DJa^(Cjx%kt&=9;OJ8%V-FKBHAZm7@DZ znAZ31K^+3EsHj#_ty9#PCDrVuH}c%zRa>$tdP8;5ngCgf-a|YJVz_}|JmA=1+(Qh% z0CgdUj-Ax>ZL77(j~`4z{Fct5Z?gmgcK-4I$U{aXw0vY_iy=`5gc$U_9p^Yu?x6g8O`d??&xi ok-=&;Jmn(pZC^8F$FKOW@p(Y%6zBF&U Tags.get_tag!(tag.id) end + end + + test "change_tag/1 returns a tag changeset" do + tag = tag_fixture() + assert %Ecto.Changeset{} = Tags.change_tag(tag) + end + end +end diff --git a/test/support/fixtures/tags_fixtures.ex b/test/support/fixtures/tags_fixtures.ex new file mode 100644 index 0000000..4fea40f --- /dev/null +++ b/test/support/fixtures/tags_fixtures.ex @@ -0,0 +1,26 @@ +defmodule Chesstrainer.TagsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Chesstrainer.Tags` context. + """ + + @doc """ + Generate a unique tag name. + """ + def unique_tag_name, do: "some name#{System.unique_integer([:positive])}" + + @doc """ + Generate a tag. + """ + def tag_fixture(attrs \\ %{}) do + {:ok, tag} = + attrs + |> Enum.into(%{ + category: "some category", + name: unique_tag_name() + }) + |> Chesstrainer.Tags.create_tag() + + tag + end +end