Compare commits

3 Commits
Author SHA1 Message Date
owenrees 85484a7b07 Feature/moves layout (#2)
* move list added and change position, remove extra info not needed

* capitalise color to move

* fix move list so that move pairs render correctly
2026-08-18 22:35:40 +02:00
owenrees 7717278561 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
2026-08-11 22:38:18 +02:00
owenrees ca51546be0 allow graft mcp to be used within opencode 2026-08-11 20:48:00 +02:00
23 changed files with 868 additions and 103 deletions
+3 -1
View File
@@ -28,10 +28,12 @@ chesstrainer-*.tar
# Ignore assets that are produced by build tools. # Ignore assets that are produced by build tools.
/priv/static/assets/ /priv/static/assets/
# Ignore ETS table backups
/priv/static/backups/
# Ignore digested assets cache. # Ignore digested assets cache.
/priv/static/cache_manifest.json /priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these. # In case you use Node.js/npm, you want to ignore these.
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
+5
View File
@@ -0,0 +1,5 @@
# graft's cards are gitignored but should stay greppable: ripgrep reads
# .ignore before .gitignore, so this re-admits the tree to search only.
!graft/
graft/.cache/
graft/.graph/
+57 -8
View File
@@ -1,5 +1,49 @@
This is a web application written using the Phoenix web framework. This is a web application written using the Phoenix web framework.
<!-- graft:start -->
## Graft — repo context graph
This repo is indexed in `graft/`: small linked markdown nodes that explain each
system and carry exact file:line spans, kept in sync with the code through git.
For ANY task here — understanding how something works, finding where code lives,
or scoping a change — get context from the graph before grepping or opening
source files. Re-ask freely (it's cheap) and reuse literal identifiers you
already have (symbol, error string, file name) as the query. New to this repo?
Run `graft map` first — a token-budgeted orientation (dir clusters, hubs,
hotspots), no LLM, no key.
- Run `graft ask "<your question>" --source` → ranked nodes with the relevant
code spans inlined (each hit's ≤8-line crux by default; `--full` for whole
definitions when the crux isn't enough). Match the tool to the task shape:
for understanding or editing, the top node IS the answer — cite its
`covers:` file:line spans and edit straight from `--source`. For
exhaustive tasks ("every occurrence / every caller of this pattern"), ranked
results are top-N, not complete — run `graft grep "<literal>"` instead
(exhaustive over indexed files, grouped by enclosing symbol), falling back
to raw `grep -rn` only for unindexed files.
- `graft skeleton <file>` → every definition's signature + span, ~10× cheaper
than reading the file; use it to skim an API surface.
- `graft callers <symbol>` gives precomputed, exact edges — who calls this.
Add `--direction out` for what it calls, or `--depth N` to walk
transitively for the full blast radius. For structural questions, skip
ranking and use this directly.
- Or browse: `graft/INDEX.md` lists every node; follow the links.
- Monorepos and folders of multiple repos rank fairly across sub-projects —
hits carry `[scope/]` labels naming which one they're from. Narrow with
`graft ask "<task>" --in <scope>/` once you know where you're working.
If a returned span is truncated ("+N more lines"), open the file at that exact
range before finalizing. Only open source files when a node genuinely lacks a
needed detail, and then at the exact file:line the node points to — never
re-read whole files.
After big code changes, refresh the graph with `graft build` (deterministic,
no API key, $0).
<!-- graft:end -->
## Project guidelines ## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues - Use `mix precommit` alias when you are done with all changes and fix any pending issues
@@ -16,7 +60,7 @@ This is a web application written using the Phoenix web framework.
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar - Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors - **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your - If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input custom classes must fully style the input
### JS and CSS guidelines ### JS and CSS guidelines
@@ -43,10 +87,10 @@ custom classes must fully style the input
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look - Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions - Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start --> <!-- usage-rules-start -->
<!-- phoenix:elixir-start --> <!-- phoenix:elixir-start -->
## Elixir guidelines ## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax** - Elixir lists **do not support index based access via the access syntax**
@@ -64,7 +108,7 @@ custom classes must fully style the input
Enum.at(mylist, i) Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc - Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: you _must_ bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned # INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do if connected?(socket) do
@@ -101,9 +145,10 @@ custom classes must fully style the input
assert_receive {:DOWN, ^ref, :process, ^pid, :normal} assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end --> <!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start --> <!-- phoenix:phoenix-start -->
## Phoenix guidelines ## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
@@ -122,6 +167,7 @@ custom classes must fully style the input
<!-- phoenix:phoenix-end --> <!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start --> <!-- phoenix:ecto-start -->
## Ecto Guidelines ## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` - **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
@@ -134,6 +180,7 @@ custom classes must fully style the input
<!-- phoenix:ecto-end --> <!-- phoenix:ecto-end -->
<!-- phoenix:html-start --> <!-- phoenix:html-start -->
## Phoenix HTML guidelines ## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` - Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
@@ -163,7 +210,7 @@ custom classes must fully style the input
... ...
<% end %> <% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`: - HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you _must_ annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation> <code phx-no-curly-interpolation>
let obj = {key: "val"} let obj = {key: "val"}
@@ -210,9 +257,11 @@ custom classes must fully style the input
{if @invalid_block_construct do} {if @invalid_block_construct do}
{end} {end}
</div> </div>
<!-- phoenix:html-end -->
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start --> <!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines ## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews - **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
@@ -235,7 +284,7 @@ custom classes must fully style the input
</div> </div>
</div> </div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: - LiveView streams are _not_ enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter # re-fetch the messages based on the filter
@@ -248,7 +297,7 @@ custom classes must fully style the input
|> stream(:messages, messages, reset: true)} |> stream(:messages, messages, reset: true)}
end end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: - LiveView streams _do not support counting or empty states_. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream"> <div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div> <div class="hidden only:block">No tasks yet</div>
+66 -16
View File
@@ -4,27 +4,18 @@ import { Chess } from "chess.js";
export const ChessBoard = { export const ChessBoard = {
mounted() { mounted() {
this.chess = new Chess(); this.chess = new Chess();
this.ground = null;
this.ground = Chessground(this.el, { this.handleEvent("init_board", (payload) => {
fen: this.chess.fen(), this.initBoard(payload);
movable: {
color: "white",
free: false,
dests: this.getValidDests(),
},
events: {
move: (orig, dest, _capturedPiece) => {
this.handleMove(orig, dest);
},
},
}); });
this.handleEvent("set_fen", ({ fen }) => { this.handleEvent("set_fen", ({ fen }) => {
try { try {
this.chess.load(fen); this.chess.load(fen);
if (this.ground) {
const currentTurnColor = this.chess.turn() === "w" ? "white" : "black"; const currentTurnColor = this.chess.turn() === "w" ? "white" : "black";
this.ground.set({ this.ground.set({
fen: fen, fen: fen,
turnColor: currentTurnColor, turnColor: currentTurnColor,
@@ -33,11 +24,59 @@ export const ChessBoard = {
dests: this.getValidDests(), dests: this.getValidDests(),
}, },
}); });
} else {
this.initBoard({
fen,
orientation: "white",
player_color: "white",
});
}
} catch (error) { } catch (error) {
console.error("Invalid FEN string submitted:", error); console.error("Invalid FEN string submitted:", error);
alert("Invalid FEN configuration!"); 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: fen,
orientation: orientation,
turnColor: turnColor,
movable: {
color: player_color,
free: false,
dests: this.getValidDests(),
},
events: {
move: (orig, dest, _capturedPiece) => {
this.handleMove(orig, dest);
},
},
});
}, },
getValidDests() { getValidDests() {
@@ -56,7 +95,7 @@ export const ChessBoard = {
const destRank = dest[1]; const destRank = dest[1];
if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) { if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) {
this.showPromotionDialog(orig, dest); this.showPromotionDialog(orig, dest);
return; // don't execute yet — wait for user choice return;
} }
this.executeMove(orig, dest); this.executeMove(orig, dest);
}, },
@@ -98,12 +137,23 @@ export const ChessBoard = {
const move = this.chess.move({ from: orig, to: dest, promotion }); const move = this.chess.move({ from: orig, to: dest, promotion });
if (move) { if (move) {
this.pushEvent("move_played", { const eventName =
this.el.dataset && this.el.dataset.endgameId
? "endgame_move_played"
: "move_played";
const payload = {
from: orig, from: orig,
to: dest, to: dest,
fen: this.chess.fen(), fen: this.chess.fen(),
san: move.san, 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"; const nextTurnColor = this.chess.turn() === "w" ? "white" : "black";
+81 -1
View File
@@ -30,12 +30,92 @@ defmodule Chesstrainer.Endgames.Endgame do
@doc false @doc false
def changeset(endgame, attrs) do def changeset(endgame, attrs) do
endgame 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([:fen, :color], message: "Invalid FEN")
|> validate_required([:key, :result, :rating]) |> validate_required([:key, :result, :rating])
|> validate_format(:key, ~r/^(?=.{5,10}$)KQ*R*[NB]*P*\sv\sKQ*R*[NB]*P*$/, |> 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" 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") |> unique_constraint(:fen, message: "FEN already exists")
end 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 end
+33
View File
@@ -7,6 +7,39 @@ defmodule Chesstrainer.FEN do
|> color_initial_to_color_atom() |> color_initial_to_color_atom()
end 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("b"), do: :black
defp color_initial_to_color_atom("w"), do: :white defp color_initial_to_color_atom("w"), do: :white
defp color_initial_to_color_atom(_), do: nil defp color_initial_to_color_atom(_), do: nil
+19
View File
@@ -101,4 +101,23 @@ defmodule Chesstrainer.Tags do
def change_tag(%Tag{} = tag, attrs \\ %{}) do def change_tag(%Tag{} = tag, attrs \\ %{}) do
Tag.changeset(tag, attrs) Tag.changeset(tag, attrs)
end 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 end
+18 -12
View File
@@ -34,6 +34,20 @@ defmodule ChesstrainerWeb.Layouts do
slot :inner_block, required: true slot :inner_block, required: true
def app(assigns) do def app(assigns) do
~H"""
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Renders the application navbar.
"""
def navbar(assigns) do
~H""" ~H"""
<header class="navbar px-4 sm:px-6 lg:px-8"> <header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1"> <div class="flex-1">
@@ -45,30 +59,22 @@ defmodule ChesstrainerWeb.Layouts do
<div class="flex-none"> <div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center"> <ul class="flex flex-column px-1 space-x-4 items-center">
<li> <li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a> <a href="/endgames" class="btn btn-ghost">Endgames</a>
</li> </li>
<li> <li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a> <a href="/admin/endgames" class="btn btn-ghost">Admin</a>
</li> </li>
<li> <li>
<.theme_toggle /> <.theme_toggle />
</li> </li>
<li> <li>
<a href="https://phoenix.hexdocs.pm/overview.html" class="btn btn-primary"> <a href="/" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span> Login / Signup
</a> </a>
</li> </li>
</ul> </ul>
</div> </div>
</header> </header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
""" """
end end
@@ -40,6 +40,7 @@
</script> </script>
</head> </head>
<body> <body>
<Layouts.navbar />
{@inner_content} {@inner_content}
</body> </body>
</html> </html>
@@ -1,43 +1,4 @@
<Layouts.flash_group flash={@flash} /> <Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32"> <div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0"> <div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true"> <svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
@@ -34,6 +34,7 @@ defmodule ChesstrainerWeb.EndgameLive.Index do
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link> <.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link>
</div> </div>
<.link navigate={~p"/endgames/#{endgame}/play"}>Play</.link>
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link> <.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link>
</:action> </:action>
<:action :let={{id, endgame}}> <:action :let={{id, endgame}}>
@@ -0,0 +1,128 @@
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 |> Atom.to_string() |> String.capitalize()} to move · Rating: {@endgame.rating}
</:subtitle>
<:actions>
<.button navigate={~p"/endgames"}>
<.icon name="hero-arrow-left" /> Back to endgames
</.button>
</:actions>
</.header>
<div class="flex gap-2 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-100 h-100"
>
</div>
<div class="w-48 h-100 flex flex-col gap-3">
<div class="flex-1 min-h-0 flex flex-col bg-white rounded-lg p-3">
<p class="text-sm text-gray-600 font-semibold mb-2">Moves:</p>
<div class="flex-1 overflow-y-auto">
<%= if @move_list == [] do %>
<p class="text-sm text-gray-500">No Moves</p>
<% else %>
<%= for {[white_move, black_move], i} <- format_move_pairs(@move_list, @endgame.color) do %>
<div class="font-mono text-sm leading-6">
<span class="text-gray-500 mr-2">{i}.</span>
<span class="text-gray-900">{white_move || "..."}</span>
<%= if black_move do %>
<span class="text-gray-900 ml-2">{black_move}</span>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
<div>Buttons</div>
<div>
<.button phx-click="reset" variant="primary">
<.icon name="hero-arrow-path" /> Reset
</.button>
</div>
</div>
</div>
</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, socket.assigns.move_list ++ [san])}
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
def format_move_pairs(move_list, color) do
moves = if color in [:black, "black"], do: [nil | move_list], else: move_list
moves
|> Enum.chunk_every(2, 2, [nil])
|> Enum.with_index(1)
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"}> <.button navigate={~p"/admin/endgames"}>
<.icon name="hero-arrow-left" /> <.icon name="hero-arrow-left" />
</.button> </.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"}> <.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit endgame <.icon name="hero-pencil-square" /> Edit endgame
</.button> </.button>
+3
View File
@@ -20,6 +20,9 @@ defmodule ChesstrainerWeb.Router do
get "/", PageController, :home get "/", PageController, :home
live "/chess-test", ChessTestLive live "/chess-test", ChessTestLive
live "/endgames", EndgameLive.PlayerIndex, :index
live "/endgames/:id/play", EndgameLive.Play, :play
end end
scope "/admin", ChesstrainerWeb do scope "/admin", ChesstrainerWeb do
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"graft": {
"type": "local",
"command": ["npx", "-y", "@nanonets/graft", "mcp"],
"enabled": true
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ Repo.insert_all(Endgame, [
}, },
%{ %{
id: Ecto.UUID.generate(), 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, color: :black,
key: "KPPP v KPP", key: "KPPP v KPP",
message: "3 pawns vs 2 pawns", message: "3 pawns vs 2 pawns",
+39
View File
@@ -0,0 +1,39 @@
defmodule Chesstrainer.CacheTest do
use ExUnit.Case, async: true
alias Chesstrainer.Cache
setup do
table = :"test_#{System.unique_integer([:positive])}"
{:ok, pid} = Cache.start_link(table)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
{:ok, %{pid: pid, table: table}}
end
describe "ChessTrainer.Cache function tests" do
test "cache is started", %{pid: pid, table: table} do
assert is_pid(pid)
assert 0 = Cache.size(table)
end
test "put/3", %{table: table} do
assert :ok = Cache.put(table, "key1", "value1")
assert {:ok, "value1"} = Cache.get(table, "key1")
assert 1 = Cache.size(table)
assert :ok = Cache.put(table, "key2", "value2")
assert {:ok, "value2"} = Cache.get(table, "key2")
assert 2 = Cache.size(table)
end
test "delete/2", %{table: table} do
assert :ok = Cache.put(table, "key1", "value1")
assert :ok = Cache.put(table, "key2", "value2")
assert 2 = Cache.size(table)
assert :ok = Cache.delete(table, "key1")
assert 1 = Cache.size(table)
assert :error = Cache.get(table, "key1")
end
end
end
+88
View File
@@ -0,0 +1,88 @@
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 "normalizes fullmove 0 to 1 before inserting" 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 {:ok, endgame} = Endgames.create_endgame(attrs)
assert endgame.fen == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1"
end
test "trims whitespace around the FEN before inserting" do
attrs = %{
fen: " 8/8/3k4/8/8/3K1R2/8/8 w - - 0 1\t\n",
color: :white,
key: "KR v K",
result: :win,
rating: 1500
}
assert {:ok, endgame} = Endgames.create_endgame(attrs)
assert endgame.fen == "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1"
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
+62
View File
@@ -0,0 +1,62 @@
defmodule Chesstrainer.FENTest do
use ExUnit.Case, async: true
alias Chesstrainer.FEN
describe "color_from_fen/1" do
test "extracts white from the active-color field" do
assert FEN.color_from_fen("8/8/3k4/8/8/3K1R2/8/8 w - - 0 1") == :white
end
test "extracts black from the active-color field" do
assert FEN.color_from_fen("6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1") == :black
end
test "returns nil when the active-color field is missing" do
assert FEN.color_from_fen("8/8/8/8/8/8/8/8") == nil
end
end
describe "normalize/1" do
test "trims surrounding whitespace" do
fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1"
assert FEN.normalize(" \n" <> fen <> "\t\n") == fen
end
test "rewrites fullmove 0 to 1" do
fen = "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0"
assert FEN.normalize(fen) == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1"
end
test "rewrites fullmove 0 even when the FEN has surrounding whitespace" do
fen = "\n 6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0 \t"
assert FEN.normalize(fen) == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1"
end
test "leaves a non-zero fullmove untouched" do
fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 5"
assert FEN.normalize(fen) == fen
end
test "leaves a standard starting position untouched" do
fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
assert FEN.normalize(fen) == fen
end
test "passes through a non-FEN string unchanged" do
assert FEN.normalize("not a fen") == "not a fen"
end
test "passes through a partial FEN (fewer than 6 fields) unchanged" do
fen = "8/8/3k4/8/8/3K1R2/8/8 w - -"
assert FEN.normalize(fen) == fen
end
test "passes through a FEN with a leading-zero fullmove unchanged" do
# Fullmove 0 only matches the literal "0"; leading zeros stay so the
# validator can surface them rather than silently masking the typo.
fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 00"
assert FEN.normalize(fen) == fen
end
end
end
@@ -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
@@ -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
+44
View File
@@ -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