mirror of
https://github.com/TheRealOwenRees/chess-endgame-trainer.git
synced 2026-09-19 19:13:52 +00:00
114 lines
3.1 KiB
Elixir
114 lines
3.1 KiB
Elixir
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"
|
|
)
|
|
|> 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
|