mirror of
https://github.com/TheRealOwenRees/chess-endgame-trainer.git
synced 2026-09-19 11:03:52 +00:00
create endgame play section
This commit is contained in:
@@ -36,6 +36,78 @@ defmodule Chesstrainer.Endgames.Endgame do
|
||||
|> 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"
|
||||
)
|
||||
|> validate_change(:fen, fn :fen, fen ->
|
||||
case fen_issues(fen) do
|
||||
[] -> []
|
||||
issues -> [fen: "is not a valid FEN: #{Enum.join(issues, "; ")}"]
|
||||
end
|
||||
end)
|
||||
|> unique_constraint(:fen, message: "FEN already exists")
|
||||
end
|
||||
|
||||
# Strict structural validation. Mirrors chess.js rules so the FEN survives
|
||||
# both the server-side parser (Chex, permissive) and the client-side parser
|
||||
# (chess.js, strict).
|
||||
defp fen_issues(fen) do
|
||||
case String.split(fen, " ") do
|
||||
[board, active, _castling, _en_passant, halfmove, fullmove] ->
|
||||
issues = []
|
||||
|
||||
issues =
|
||||
if valid_board?(board),
|
||||
do: issues,
|
||||
else: ["board must be 8 ranks of 8 squares each"]
|
||||
|
||||
issues =
|
||||
if active in ["w", "b"],
|
||||
do: issues,
|
||||
else: ["active color must be 'w' or 'b'"]
|
||||
|
||||
issues =
|
||||
if non_negative_integer?(halfmove),
|
||||
do: issues,
|
||||
else: ["halfmove clock must be a non-negative integer"]
|
||||
|
||||
issues =
|
||||
if positive_integer?(fullmove),
|
||||
do: issues,
|
||||
else: ["fullmove number must be a positive integer"]
|
||||
|
||||
issues
|
||||
|
||||
_ ->
|
||||
["must have 6 space-separated fields"]
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_board?(board) do
|
||||
ranks = String.split(board, "/")
|
||||
|
||||
length(ranks) == 8 and Enum.all?(ranks, &valid_rank?/1)
|
||||
end
|
||||
|
||||
defp valid_rank?(rank) do
|
||||
squares =
|
||||
rank
|
||||
|> String.graphemes()
|
||||
|> Enum.reduce(0, fn
|
||||
g, acc when byte_size(g) == 1 ->
|
||||
c = :binary.first(g)
|
||||
|
||||
if c >= ?1 and c <= ?8 do
|
||||
acc + (c - ?0)
|
||||
else
|
||||
acc + 1
|
||||
end
|
||||
end)
|
||||
|
||||
squares == 8
|
||||
end
|
||||
|
||||
defp non_negative_integer?(str), do: integer_string?(str) and String.to_integer(str) >= 0
|
||||
defp positive_integer?(str), do: integer_string?(str) and String.to_integer(str) >= 1
|
||||
|
||||
defp integer_string?(str) do
|
||||
Regex.match?(~r/^[0-9]+$/, str)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -101,4 +101,23 @@ defmodule Chesstrainer.Tags do
|
||||
def change_tag(%Tag{} = tag, attrs \\ %{}) do
|
||||
Tag.changeset(tag, attrs)
|
||||
end
|
||||
|
||||
def search("", _category), do: []
|
||||
|
||||
def search(query, category) when is_binary(query) do
|
||||
pattern = "%#{query}%"
|
||||
|
||||
Tag
|
||||
|> where([t], ilike(t.name, ^pattern) and t.category == ^category)
|
||||
|> order_by([t], asc: t.name)
|
||||
|> limit(20)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_or_create(name, category) when is_binary(name) do
|
||||
case Repo.get_by(Tag, name: name, category: category) do
|
||||
nil -> create_tag(%{name: name, category: category})
|
||||
tag -> {:ok, tag}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,6 +34,7 @@ defmodule ChesstrainerWeb.EndgameLive.Index do
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link>
|
||||
</div>
|
||||
<.link navigate={~p"/endgames/#{endgame}/play"}>Play</.link>
|
||||
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
<:action :let={{id, endgame}}>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
defmodule ChesstrainerWeb.EndgameLive.Play do
|
||||
use ChesstrainerWeb, :live_view
|
||||
|
||||
alias Chesstrainer.Endgames
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
Play endgame {@endgame.key}
|
||||
<:subtitle>
|
||||
{@endgame.color} to move · Result: {@endgame.result} · Rating: {@endgame.rating}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button navigate={~p"/endgames"}>
|
||||
<.icon name="hero-arrow-left" /> Back to endgames
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="bg-gray-100 p-4 rounded mb-4 flex justify-between items-center shadow-sm">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 font-semibold">Last Played Move:</p>
|
||||
<p class="text-lg font-mono text-blue-600">{@last_from_to}</p>
|
||||
<p class="text-lg font-mono text-blue-600">{@last_san}</p>
|
||||
<p class="text-lg font-mono text-emerald-600">
|
||||
{if @move_list == [] do
|
||||
"No Moves"
|
||||
else
|
||||
Enum.reverse(@move_list) |> Enum.join(", ")
|
||||
end}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<.button phx-click="reset" variant="primary">
|
||||
<.icon name="hero-arrow-path" /> Reset
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center bg-gray-200 p-4 rounded-xl shadow-inner">
|
||||
<div
|
||||
id="endgame-board"
|
||||
phx-hook="ChessBoard"
|
||||
phx-update="ignore"
|
||||
data-endgame-id={@endgame.id}
|
||||
class="w-[400px] h-[400px]"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.list>
|
||||
<:item title="Fen">{@endgame.fen}</:item>
|
||||
<:item title="Key">{@endgame.key}</:item>
|
||||
<:item title="Color">{@endgame.color}</:item>
|
||||
<:item title="Result">{@endgame.result}</:item>
|
||||
<:item title="Rating">{@endgame.rating}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
endgame = Endgames.get_endgame!(id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Play endgame")
|
||||
|> assign(:endgame, endgame)
|
||||
|> assign(:move_list, [])
|
||||
|> assign(:last_san, "None yet")
|
||||
|> assign(:last_from_to, "None yet")
|
||||
|> push_event("init_board", %{
|
||||
fen: endgame.fen,
|
||||
orientation: Atom.to_string(endgame.color),
|
||||
player_color: Atom.to_string(endgame.color)
|
||||
})
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"endgame_move_played",
|
||||
%{"from" => from, "to" => to, "san" => san},
|
||||
socket
|
||||
) do
|
||||
# TODO: server-side legality check + tablebase evaluation when wired
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:last_from_to, "#{from} ➔ #{to}")
|
||||
|> assign(:last_san, san)
|
||||
|> assign(:move_list, [san | socket.assigns.move_list])}
|
||||
end
|
||||
|
||||
def handle_event("chess_fen_invalid", %{"fen" => fen, "message" => message}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Endgame FEN is invalid and the board cannot be loaded: #{message} (#{fen})"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("reset", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:move_list, [])
|
||||
|> assign(:last_san, "None yet")
|
||||
|> assign(:last_from_to, "None yet")
|
||||
|> push_event("init_board", %{
|
||||
fen: socket.assigns.endgame.fen,
|
||||
orientation: Atom.to_string(socket.assigns.endgame.color),
|
||||
player_color: Atom.to_string(socket.assigns.endgame.color)
|
||||
})}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
defmodule ChesstrainerWeb.EndgameLive.PlayerIndex do
|
||||
use ChesstrainerWeb, :live_view
|
||||
|
||||
alias Chesstrainer.Endgames
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
Endgames
|
||||
<:subtitle>Pick an endgame to play.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="player-endgames"
|
||||
rows={@streams.endgames}
|
||||
>
|
||||
<:col :let={{_id, endgame}} label="Key">{endgame.key}</:col>
|
||||
<:col :let={{_id, endgame}} label="Color">{endgame.color}</:col>
|
||||
<:col :let={{_id, endgame}} label="Result">{endgame.result}</:col>
|
||||
<:col :let={{_id, endgame}} label="Rating">{endgame.rating}</:col>
|
||||
<:action :let={{_id, endgame}}>
|
||||
<.link navigate={~p"/endgames/#{endgame}/play"}>Play</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Endgames")
|
||||
|> stream(:endgames, Endgames.list_endgames())}
|
||||
end
|
||||
end
|
||||
@@ -31,6 +31,9 @@ defmodule ChesstrainerWeb.EndgameLive.Show do
|
||||
<.button navigate={~p"/admin/endgames"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button navigate={~p"/endgames/#{@endgame}/play"}>
|
||||
<.icon name="hero-play" /> Play (opens trainer)
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> Edit endgame
|
||||
</.button>
|
||||
|
||||
@@ -20,6 +20,9 @@ defmodule ChesstrainerWeb.Router do
|
||||
get "/", PageController, :home
|
||||
|
||||
live "/chess-test", ChessTestLive
|
||||
|
||||
live "/endgames", EndgameLive.PlayerIndex, :index
|
||||
live "/endgames/:id/play", EndgameLive.Play, :play
|
||||
end
|
||||
|
||||
scope "/admin", ChesstrainerWeb do
|
||||
|
||||
Reference in New Issue
Block a user