From 88bb016bac13687989b2561e5e3af15d1b6205e6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 21:52:33 +0200 Subject: [PATCH] create endgame play section --- assets/js/hooks/chessBoard.js | 100 +++++++++++---- lib/chesstrainer/endgames/endgame.ex | 72 +++++++++++ lib/chesstrainer/tags.ex | 19 +++ .../live/endgame_live/index.ex | 1 + .../live/endgame_live/play.ex | 119 ++++++++++++++++++ .../live/endgame_live/player_index.ex | 38 ++++++ .../live/endgame_live/show.ex | 3 + lib/chesstrainer_web/router.ex | 3 + priv/repo/seeds.exs | 2 +- test/chesstrainer/endgames_test.exs | 76 +++++++++++ .../live/endgame_live/play_test.exs | 115 +++++++++++++++++ .../live/endgame_live/player_index_test.exs | 28 +++++ test/support/fixtures/endgame_fixtures.ex | 44 +++++++ 13 files changed, 594 insertions(+), 26 deletions(-) create mode 100644 lib/chesstrainer_web/live/endgame_live/play.ex create mode 100644 lib/chesstrainer_web/live/endgame_live/player_index.ex create mode 100644 test/chesstrainer/endgames_test.exs create mode 100644 test/chesstrainer_web/live/endgame_live/play_test.exs create mode 100644 test/chesstrainer_web/live/endgame_live/player_index_test.exs create mode 100644 test/support/fixtures/endgame_fixtures.ex 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