diff --git a/assets/js/hooks/chessBoard.js b/assets/js/hooks/chessBoard.js
index 68f796c..5270610 100644
--- a/assets/js/hooks/chessBoard.js
+++ b/assets/js/hooks/chessBoard.js
@@ -4,11 +4,70 @@ import { Chess } from "chess.js";
export const ChessBoard = {
mounted() {
this.chess = new Chess();
+ this.ground = null;
+
+ this.handleEvent("init_board", (payload) => {
+ this.initBoard(payload);
+ });
+
+ this.handleEvent("set_fen", ({ fen }) => {
+ try {
+ this.chess.load(fen);
+
+ if (this.ground) {
+ const currentTurnColor = this.chess.turn() === "w" ? "white" : "black";
+ this.ground.set({
+ fen: fen,
+ turnColor: currentTurnColor,
+ movable: {
+ color: currentTurnColor,
+ dests: this.getValidDests(),
+ },
+ });
+ } else {
+ this.initBoard({
+ fen,
+ orientation: "white",
+ player_color: "white",
+ });
+ }
+ } catch (error) {
+ console.error("Invalid FEN string submitted:", error);
+ alert("Invalid FEN configuration!");
+ }
+ });
+
+ this.initBoard({
+ fen: this.chess.fen(),
+ orientation: "white",
+ player_color: "white",
+ });
+ },
+
+ initBoard({ fen, orientation, player_color }) {
+ try {
+ this.chess.load(fen);
+ } catch (error) {
+ console.error("Invalid FEN from server:", fen, error);
+ this.pushEvent("chess_fen_invalid", {
+ fen: fen,
+ message: error && error.message ? error.message : String(error),
+ });
+ return;
+ }
+
+ if (this.ground) {
+ this.ground.destroy();
+ }
+
+ const turnColor = this.chess.turn() === "w" ? "white" : "black";
this.ground = Chessground(this.el, {
- fen: this.chess.fen(),
+ fen: fen,
+ orientation: orientation,
+ turnColor: turnColor,
movable: {
- color: "white",
+ color: player_color,
free: false,
dests: this.getValidDests(),
},
@@ -18,26 +77,6 @@ export const ChessBoard = {
},
},
});
-
- this.handleEvent("set_fen", ({ fen }) => {
- try {
- this.chess.load(fen);
-
- const currentTurnColor = this.chess.turn() === "w" ? "white" : "black";
-
- this.ground.set({
- fen: fen,
- turnColor: currentTurnColor,
- movable: {
- color: currentTurnColor,
- dests: this.getValidDests(),
- },
- });
- } catch (error) {
- console.error("Invalid FEN string submitted:", error);
- alert("Invalid FEN configuration!");
- }
- });
},
getValidDests() {
@@ -56,7 +95,7 @@ export const ChessBoard = {
const destRank = dest[1];
if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) {
this.showPromotionDialog(orig, dest);
- return; // don't execute yet — wait for user choice
+ return;
}
this.executeMove(orig, dest);
},
@@ -98,12 +137,23 @@ export const ChessBoard = {
const move = this.chess.move({ from: orig, to: dest, promotion });
if (move) {
- this.pushEvent("move_played", {
+ const eventName =
+ this.el.dataset && this.el.dataset.endgameId
+ ? "endgame_move_played"
+ : "move_played";
+
+ const payload = {
from: orig,
to: dest,
fen: this.chess.fen(),
san: move.san,
- });
+ };
+
+ if (eventName === "endgame_move_played") {
+ payload.endgame_id = this.el.dataset.endgameId;
+ }
+
+ this.pushEvent(eventName, payload);
const nextTurnColor = this.chess.turn() === "w" ? "white" : "black";
diff --git a/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex
index 65a79d9..cc87a34 100644
--- a/lib/chesstrainer/endgames/endgame.ex
+++ b/lib/chesstrainer/endgames/endgame.ex
@@ -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
diff --git a/lib/chesstrainer/tags.ex b/lib/chesstrainer/tags.ex
index 42c1efc..27d099a 100644
--- a/lib/chesstrainer/tags.ex
+++ b/lib/chesstrainer/tags.ex
@@ -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
diff --git a/lib/chesstrainer_web/live/endgame_live/index.ex b/lib/chesstrainer_web/live/endgame_live/index.ex
index 91a5fc9..0de26bf 100644
--- a/lib/chesstrainer_web/live/endgame_live/index.ex
+++ b/lib/chesstrainer_web/live/endgame_live/index.ex
@@ -34,6 +34,7 @@ defmodule ChesstrainerWeb.EndgameLive.Index do
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show
+ <.link navigate={~p"/endgames/#{endgame}/play"}>Play
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit
<:action :let={{id, endgame}}>
diff --git a/lib/chesstrainer_web/live/endgame_live/play.ex b/lib/chesstrainer_web/live/endgame_live/play.ex
new file mode 100644
index 0000000..cca5edf
--- /dev/null
+++ b/lib/chesstrainer_web/live/endgame_live/play.ex
@@ -0,0 +1,119 @@
+defmodule ChesstrainerWeb.EndgameLive.Play do
+ use ChesstrainerWeb, :live_view
+
+ alias Chesstrainer.Endgames
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Play endgame {@endgame.key}
+ <:subtitle>
+ {@endgame.color} to move · Result: {@endgame.result} · Rating: {@endgame.rating}
+
+ <:actions>
+ <.button navigate={~p"/endgames"}>
+ <.icon name="hero-arrow-left" /> Back to endgames
+
+
+
+
+
+
+
Last Played Move:
+
{@last_from_to}
+
{@last_san}
+
+ {if @move_list == [] do
+ "No Moves"
+ else
+ Enum.reverse(@move_list) |> Enum.join(", ")
+ end}
+
+
+
+ <.button phx-click="reset" variant="primary">
+ <.icon name="hero-arrow-path" /> Reset
+
+
+
+
+
+
+ <.list>
+ <:item title="Fen">{@endgame.fen}
+ <:item title="Key">{@endgame.key}
+ <:item title="Color">{@endgame.color}
+ <:item title="Result">{@endgame.result}
+ <:item title="Rating">{@endgame.rating}
+
+
+ """
+ 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
diff --git a/lib/chesstrainer_web/live/endgame_live/player_index.ex b/lib/chesstrainer_web/live/endgame_live/player_index.ex
new file mode 100644
index 0000000..4311f9a
--- /dev/null
+++ b/lib/chesstrainer_web/live/endgame_live/player_index.ex
@@ -0,0 +1,38 @@
+defmodule ChesstrainerWeb.EndgameLive.PlayerIndex do
+ use ChesstrainerWeb, :live_view
+
+ alias Chesstrainer.Endgames
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Endgames
+ <:subtitle>Pick an endgame to play.
+
+
+ <.table
+ id="player-endgames"
+ rows={@streams.endgames}
+ >
+ <:col :let={{_id, endgame}} label="Key">{endgame.key}
+ <:col :let={{_id, endgame}} label="Color">{endgame.color}
+ <:col :let={{_id, endgame}} label="Result">{endgame.result}
+ <:col :let={{_id, endgame}} label="Rating">{endgame.rating}
+ <:action :let={{_id, endgame}}>
+ <.link navigate={~p"/endgames/#{endgame}/play"}>Play
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, "Endgames")
+ |> stream(:endgames, 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
index c5d6811..3900adb 100644
--- a/lib/chesstrainer_web/live/endgame_live/show.ex
+++ b/lib/chesstrainer_web/live/endgame_live/show.ex
@@ -31,6 +31,9 @@ defmodule ChesstrainerWeb.EndgameLive.Show do
<.button navigate={~p"/admin/endgames"}>
<.icon name="hero-arrow-left" />
+ <.button navigate={~p"/endgames/#{@endgame}/play"}>
+ <.icon name="hero-play" /> Play (opens trainer)
+
<.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit endgame
diff --git a/lib/chesstrainer_web/router.ex b/lib/chesstrainer_web/router.ex
index 59fb058..beb9017 100644
--- a/lib/chesstrainer_web/router.ex
+++ b/lib/chesstrainer_web/router.ex
@@ -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
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 179b8af..ab718a6 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -32,7 +32,7 @@ Repo.insert_all(Endgame, [
},
%{
id: Ecto.UUID.generate(),
- fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0",
+ fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1",
color: :black,
key: "KPPP v KPP",
message: "3 pawns vs 2 pawns",
diff --git a/test/chesstrainer/endgames_test.exs b/test/chesstrainer/endgames_test.exs
new file mode 100644
index 0000000..9e4d9dd
--- /dev/null
+++ b/test/chesstrainer/endgames_test.exs
@@ -0,0 +1,76 @@
+defmodule Chesstrainer.EndgamesTest do
+ use Chesstrainer.DataCase
+
+ alias Chesstrainer.Endgames
+ alias Chesstrainer.Endgames.Endgame
+
+ describe "create_endgame/1 FEN validation" do
+ test "accepts a standard valid FEN" do
+ attrs = %{
+ fen: "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1",
+ color: :white,
+ key: "KR v K",
+ result: :win,
+ rating: 1500
+ }
+
+ assert {:ok, %Endgame{}} = Endgames.create_endgame(attrs)
+ end
+
+ test "rejects a FEN with fullmove number zero" do
+ attrs = %{
+ fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0",
+ color: :black,
+ key: "KPPP v KPP",
+ result: :win,
+ rating: 1500
+ }
+
+ assert {:error, changeset} = Endgames.create_endgame(attrs)
+ assert [msg] = errors_on(changeset).fen
+ assert msg =~ "fullmove number"
+ end
+
+ test "rejects a FEN with a zero digit in a rank" do
+ attrs = %{
+ fen: "4k3/8/8/8/8/8/8/0K7 w - - 0 1",
+ color: :white,
+ key: "KQ v K",
+ result: :win,
+ rating: 1500
+ }
+
+ assert {:error, changeset} = Endgames.create_endgame(attrs)
+ assert [msg] = errors_on(changeset).fen
+ assert msg =~ "board must be 8 ranks"
+ end
+
+ test "rejects a FEN with the wrong number of fields" do
+ attrs = %{
+ fen: "8/8/3k4/8/8/3K1R2/8/8",
+ color: :white,
+ key: "KQ v K",
+ result: :win,
+ rating: 1500
+ }
+
+ assert {:error, changeset} = Endgames.create_endgame(attrs)
+ assert [msg] = errors_on(changeset).fen
+ assert msg =~ "6 space-separated"
+ end
+
+ test "rejects a FEN with an unknown active color" do
+ attrs = %{
+ fen: "8/8/3k4/8/8/3K1R2/8/8 x - - 0 1",
+ color: :white,
+ key: "KQ v K",
+ result: :win,
+ rating: 1500
+ }
+
+ assert {:error, changeset} = Endgames.create_endgame(attrs)
+ assert [msg] = errors_on(changeset).fen
+ assert msg =~ "active color"
+ end
+ end
+end
diff --git a/test/chesstrainer_web/live/endgame_live/play_test.exs b/test/chesstrainer_web/live/endgame_live/play_test.exs
new file mode 100644
index 0000000..b55951a
--- /dev/null
+++ b/test/chesstrainer_web/live/endgame_live/play_test.exs
@@ -0,0 +1,115 @@
+defmodule ChesstrainerWeb.EndgameLive.PlayTest do
+ use ChesstrainerWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Chesstrainer.EndgamesFixtures
+
+ defp assert_init_board(view, expected_fen, expected_orientation, expected_player_color) do
+ %{proxy: {ref, _topic, _}} = view
+
+ assert_receive {^ref, {:push_event, "init_board", payload}}, 100
+
+ assert payload.fen == expected_fen
+ assert payload.orientation == expected_orientation
+ assert payload.player_color == expected_player_color
+ end
+
+ describe "mount" do
+ test "renders the chessboard hook div and pushes init_board", %{conn: conn} do
+ endgame = endgame_fixture(color: :white)
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ assert has_element?(view, "#endgame-board")
+ assert_init_board(view, endgame.fen, "white", "white")
+ end
+
+ test "pushes black orientation when the endgame color is black", %{conn: conn} do
+ endgame = endgame_fixture(color: :black)
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ assert_init_board(view, endgame.fen, "black", "black")
+ end
+
+ test "shows endgame metadata in the header", %{conn: conn} do
+ endgame = endgame_fixture(key: "KQ v K", color: :white, result: :win, rating: 1800)
+
+ {:ok, _view, html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ assert html =~ "KQ v K"
+ assert html =~ "white to move"
+ assert html =~ "win"
+ assert html =~ "1800"
+ end
+
+ test "raises when the endgame does not exist", %{conn: conn} do
+ assert_raise Ecto.NoResultsError, fn ->
+ live(conn, ~p"/endgames/00000000-0000-0000-0000-000000000000/play")
+ end
+ end
+ end
+
+ describe "chess_fen_invalid" do
+ test "shows a flash when the chessground hook reports an unparseable FEN", %{conn: conn} do
+ endgame = endgame_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ view
+ |> render_hook("chess_fen_invalid", %{
+ "fen" => endgame.fen,
+ "message" => "Invalid FEN: move number must be a positive integer"
+ })
+
+ html = render(view)
+ assert html =~ "Endgame FEN is invalid"
+ assert html =~ "move number must be a positive integer"
+ end
+ end
+
+ describe "endgame_move_played" do
+ test "appends the SAN move and updates last_san / last_from_to", %{conn: conn} do
+ endgame = endgame_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ view
+ |> render_hook("endgame_move_played", %{
+ "from" => "d1",
+ "to" => "d7",
+ "san" => "Qd7+",
+ "fen" => endgame.fen,
+ "endgame_id" => endgame.id
+ })
+
+ html = render(view)
+ assert html =~ "Qd7+"
+ assert html =~ "d1 ➔ d7"
+ end
+ end
+
+ describe "reset" do
+ test "clears move list and re-pushes init_board", %{conn: conn} do
+ endgame = endgame_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ view
+ |> render_hook("endgame_move_played", %{
+ "from" => "d1",
+ "to" => "d7",
+ "san" => "Qd7+",
+ "fen" => endgame.fen,
+ "endgame_id" => endgame.id
+ })
+
+ assert render(view) =~ "Qd7+"
+
+ view |> element("button", "Reset") |> render_click()
+
+ assert render(view) =~ "No Moves"
+ assert_init_board(view, endgame.fen, "white", "white")
+ end
+ end
+end
diff --git a/test/chesstrainer_web/live/endgame_live/player_index_test.exs b/test/chesstrainer_web/live/endgame_live/player_index_test.exs
new file mode 100644
index 0000000..848ebae
--- /dev/null
+++ b/test/chesstrainer_web/live/endgame_live/player_index_test.exs
@@ -0,0 +1,28 @@
+defmodule ChesstrainerWeb.EndgameLive.PlayerIndexTest do
+ use ChesstrainerWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Chesstrainer.EndgamesFixtures
+
+ test "lists endgames with a play link per row", %{conn: conn} do
+ endgame = endgame_fixture()
+ other = endgame_fixture()
+
+ {:ok, _view, html} = live(conn, ~p"/endgames")
+
+ assert html =~ "Endgames"
+ assert html =~ endgame.key
+ assert html =~ other.key
+ assert html =~ ~p"/endgames/#{endgame}/play"
+ assert html =~ ~p"/endgames/#{other}/play"
+ end
+
+ test "navigating to a play page renders the board", %{conn: conn} do
+ endgame = endgame_fixture()
+
+ {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play")
+
+ assert has_element?(view, "#endgame-board")
+ assert has_element?(view, ~s{a[href="/endgames"]})
+ end
+end
diff --git a/test/support/fixtures/endgame_fixtures.ex b/test/support/fixtures/endgame_fixtures.ex
new file mode 100644
index 0000000..0876a5c
--- /dev/null
+++ b/test/support/fixtures/endgame_fixtures.ex
@@ -0,0 +1,44 @@
+defmodule Chesstrainer.EndgamesFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Chesstrainer.Endgames` context.
+ """
+
+ @doc """
+ Generate a unique endgame FEN.
+
+ Endgames have a unique constraint on :fen, so tests need
+ distinct FENs when inserting more than one. Each call
+ shifts the white king's file to avoid FEN collisions.
+ """
+ def unique_endgame_fen do
+ # White king on rank 1 in columns b..g, leaving 1-6 empty squares
+ # before and 6-1 after. Both before_king and after_king are valid
+ # single digits (1-8), and the rank sums to 8.
+ offset = System.unique_integer([:positive]) |> rem(6)
+ before_king = offset + 1
+ after_king = 7 - before_king
+ "4k3/8/8/8/8/8/8/#{before_king}K#{after_king} w - - 0 1"
+ end
+
+ @doc """
+ Generate an endgame.
+
+ A simple K v K position with a unique FEN, color,
+ key, rating, and result.
+ """
+ def endgame_fixture(attrs \\ %{}) do
+ {:ok, endgame} =
+ attrs
+ |> Enum.into(%{
+ fen: unique_endgame_fen(),
+ key: "KQ v K",
+ color: :white,
+ result: :win,
+ rating: 1500
+ })
+ |> Chesstrainer.Endgames.create_endgame()
+
+ endgame
+ end
+end