mirror of
https://github.com/TheRealOwenRees/chess-endgame-trainer.git
synced 2026-09-19 11:03:52 +00:00
Feature/endgame play (#1)
* create endgame play section * add cache tests * normalise FENs - trim and fuill move to 1 on addition * remove svg on root and allow navbar to exist across all pages * update nav items
This commit is contained in:
@@ -30,12 +30,92 @@ defmodule Chesstrainer.Endgames.Endgame do
|
||||
@doc false
|
||||
def changeset(endgame, attrs) do
|
||||
endgame
|
||||
|> cast(attrs, [:fen, :key, :message, :notes, :result, :rating, :color])
|
||||
|> cast(normalize_attrs(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"
|
||||
)
|
||||
|> 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
|
||||
|
||||
defp normalize_attrs(%{"fen" => fen} = attrs) when is_binary(fen),
|
||||
do: Map.put(attrs, "fen", Chesstrainer.FEN.normalize(fen))
|
||||
|
||||
defp normalize_attrs(%{fen: fen} = attrs) when is_binary(fen),
|
||||
do: Map.put(attrs, :fen, Chesstrainer.FEN.normalize(fen))
|
||||
|
||||
defp normalize_attrs(attrs), do: attrs
|
||||
|
||||
# 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
|
||||
|
||||
@@ -7,6 +7,39 @@ defmodule Chesstrainer.FEN do
|
||||
|> color_initial_to_color_atom()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a FEN string for storage.
|
||||
|
||||
Trims surrounding whitespace and rewrites a fullmove number of `"0"` to
|
||||
`"1"`. The fullmove rewrite is a chess.js-compatibility fix — chess.js
|
||||
rejects fullmove `0` as invalid even though many board editors default
|
||||
to it. Other FEN fields are left untouched; structural errors are
|
||||
surfaced by the Endgame changeset, not silently masked here.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Chesstrainer.FEN.normalize(" 8/8/3k4/8/8/3K1R2/8/8 w - - 0 0\\n ")
|
||||
"8/8/3k4/8/8/3K1R2/8/8 w - - 0 1"
|
||||
|
||||
iex> Chesstrainer.FEN.normalize("8/8/3k4/8/8/3K1R2/8/8 w - - 0 5")
|
||||
"8/8/3k4/8/8/3K1R2/8/8 w - - 0 5"
|
||||
|
||||
iex> Chesstrainer.FEN.normalize("not a fen")
|
||||
"not a fen"
|
||||
"""
|
||||
@spec normalize(String.t()) :: String.t()
|
||||
def normalize(fen) when is_binary(fen) do
|
||||
trimmed = String.trim(fen)
|
||||
|
||||
case String.split(trimmed, " ") do
|
||||
[board, active, castling, en_passant, halfmove, "0"] ->
|
||||
Enum.join([board, active, castling, en_passant, halfmove, "1"], " ")
|
||||
|
||||
_ ->
|
||||
trimmed
|
||||
end
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user