From 05606beb7e5a30931d6a32a11a7058d182ae0cc6 Mon Sep 17 00:00:00 2001 From: Simponic Date: Sun, 15 Jan 2023 16:58:01 -0700 Subject: [PATCH] Initial persistent games --- lib/chessh/schema/game.ex | 42 +++ lib/chessh/schema/player.ex | 5 +- lib/chessh/ssh/client/board/board.ex | 123 --------- lib/chessh/ssh/client/client.ex | 9 +- lib/chessh/ssh/client/game/game.ex | 255 ++++++++++++++++++ .../ssh/client/{board => game}/renderer.ex | 6 +- lib/chessh/ssh/client/menu.ex | 74 +++-- lib/chessh/ssh/tui.ex | 42 --- .../20230115201050_create_games.exs | 24 ++ 9 files changed, 393 insertions(+), 187 deletions(-) create mode 100644 lib/chessh/schema/game.ex delete mode 100644 lib/chessh/ssh/client/board/board.ex create mode 100644 lib/chessh/ssh/client/game/game.ex rename lib/chessh/ssh/client/{board => game}/renderer.ex (98%) create mode 100644 priv/repo/migrations/20230115201050_create_games.exs diff --git a/lib/chessh/schema/game.ex b/lib/chessh/schema/game.ex new file mode 100644 index 0000000..499c1b3 --- /dev/null +++ b/lib/chessh/schema/game.ex @@ -0,0 +1,42 @@ +defmodule Chessh.Game do + alias Chessh.Player + use Ecto.Schema + import Ecto.Changeset + + schema "games" do + field(:increment_sec, :integer) + field(:light_clock_ms, :integer) + field(:dark_clock_ms, :integer) + field(:last_move, :utc_datetime_usec) + + field(:fen, :string) + field(:moves, :integer, default: 0) + + field(:turn, Ecto.Enum, values: [:light, :dark], default: :light) + field(:winner, Ecto.Enum, values: [:light, :dark, :none], default: :none) + field(:status, Ecto.Enum, values: [:continue, :draw, :winner], default: :continue) + + belongs_to(:light_player, Player, foreign_key: :light_player_id) + belongs_to(:dark_player, Player, foreign_key: :dark_player_id) + + timestamps() + end + + def changeset(game, attrs) do + game + |> cast(attrs, [ + :fen, + :moves, + :turn, + :winner, + :status, + :last_move, + :increment_sec, + :light_clock_ms, + :dark_clock_ms, + :last_move, + :light_player_id, + :dark_player_id + ]) + end +end diff --git a/lib/chessh/schema/player.ex b/lib/chessh/schema/player.ex index 8eaffee..074ea4e 100644 --- a/lib/chessh/schema/player.ex +++ b/lib/chessh/schema/player.ex @@ -1,6 +1,7 @@ defmodule Chessh.Player do use Ecto.Schema import Ecto.Changeset + alias Chessh.{Key, Game} @derive {Inspect, except: [:password]} schema "players" do @@ -11,7 +12,9 @@ defmodule Chessh.Player do field(:authentications, :integer, default: 0) - has_many(:keys, Chessh.Key) + has_many(:keys, Key) + has_many(:light_games, Game, foreign_key: :light_player_id, references: :id) + has_many(:dark_games, Game, foreign_key: :dark_player_id, references: :id) timestamps() end diff --git a/lib/chessh/ssh/client/board/board.ex b/lib/chessh/ssh/client/board/board.ex deleted file mode 100644 index cbbade5..0000000 --- a/lib/chessh/ssh/client/board/board.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule Chessh.SSH.Client.Board do - require Logger - alias Chessh.Utils - alias Chessh.SSH.Client.Board.Renderer - - defmodule State do - defstruct cursor: %{x: 7, y: 7}, - highlighted: %{}, - move_from: nil, - game_id: nil, - client_pid: nil, - binbo_pid: nil, - width: 0, - height: 0, - flipped: false - end - - use Chessh.SSH.Client.Screen - - def init([%State{client_pid: client_pid, game_id: game_id} = state | _]) do - :syn.add_node_to_scopes([:games]) - :ok = :syn.join(:games, {:game, game_id}, self()) - - :binbo.start() - {:ok, binbo_pid} = :binbo.new_server() - :binbo.new_game(binbo_pid) - - send(client_pid, {:send_to_ssh, Utils.clear_codes()}) - - {:ok, %State{state | binbo_pid: binbo_pid}} - end - - def handle_info({:new_move, move}, %State{binbo_pid: binbo_pid, client_pid: client_pid} = state) do - case :binbo.move(binbo_pid, move) do - {:ok, :continue} -> - send(client_pid, {:send_to_ssh, render_state(state)}) - - _ -> - nil - end - - {:noreply, state} - end - - def input( - width, - height, - action, - %State{ - move_from: move_from, - game_id: game_id, - cursor: %{x: cursor_x, y: cursor_y} = cursor, - client_pid: client_pid, - flipped: flipped - } = state - ) do - new_cursor = - case action do - :left -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, -1, Renderer.chess_board_width())} - :right -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, 1, Renderer.chess_board_width())} - :down -> %{y: Utils.wrap_around(cursor_y, 1, Renderer.chess_board_height()), x: cursor_x} - :up -> %{y: Utils.wrap_around(cursor_y, -1, Renderer.chess_board_height()), x: cursor_x} - _ -> cursor - end - - {new_move_from, move_to} = - if action == :return do - coords = {new_cursor.y, new_cursor.x} - - case move_from do - nil -> {coords, nil} - _ -> {nil, coords} - end - else - {move_from, nil} - end - - # TODO: Check move here, then publish new move, subscribers get from DB instead - if move_from && move_to do - attempted_move = - if flipped, - do: - "#{Renderer.to_chess_coord(flip(move_from))}#{Renderer.to_chess_coord(flip(move_to))}", - else: "#{Renderer.to_chess_coord(move_from)}#{Renderer.to_chess_coord(move_to)}" - - :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) - end - - new_state = %State{ - state - | cursor: new_cursor, - move_from: new_move_from, - highlighted: %{ - {new_cursor.y, new_cursor.x} => Renderer.to_select_background(), - new_move_from => Renderer.from_select_background() - }, - width: width, - height: height, - flipped: if(action == "f", do: !flipped, else: flipped) - } - - send(client_pid, {:send_to_ssh, render_state(new_state)}) - new_state - end - - def render(width, height, %State{client_pid: client_pid} = state) do - send(client_pid, {:send_to_ssh, render_state(state)}) - %State{state | width: width, height: height} - end - - def flip({y, x}), - do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x} - - defp render_state( - %State{ - binbo_pid: binbo_pid - } = state - ) do - {:ok, fen} = :binbo.get_fen(binbo_pid) - - Renderer.render_board_state(fen, state) - end -end diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex index da9779b..21583af 100644 --- a/lib/chessh/ssh/client/client.ex +++ b/lib/chessh/ssh/client/client.ex @@ -24,8 +24,13 @@ defmodule Chessh.SSH.Client do end @impl true - def init([%State{} = state]) do - send(self(), {:set_screen_process, Chessh.SSH.Client.Menu, %Chessh.SSH.Client.Menu.State{}}) + def init([%State{player_session: player_session} = state]) do + send( + self(), + {:set_screen_process, Chessh.SSH.Client.Menu, + %Chessh.SSH.Client.Menu.State{player_session: player_session}} + ) + {:ok, state} end diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex new file mode 100644 index 0000000..1624805 --- /dev/null +++ b/lib/chessh/ssh/client/game/game.ex @@ -0,0 +1,255 @@ +defmodule Chessh.SSH.Client.Game do + require Logger + alias Chessh.{Game, Utils, Repo} + alias Chessh.SSH.Client.Game.Renderer + + @default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + defmodule State do + defstruct cursor: %{x: 7, y: 7}, + highlighted: %{}, + move_from: nil, + game: nil, + client_pid: nil, + binbo_pid: nil, + width: 0, + height: 0, + flipped: false, + color: :light, + player_session: nil + end + + use Chessh.SSH.Client.Screen + + defp initialize_game(game_id, fen) do + :syn.add_node_to_scopes([:games]) + :ok = :syn.join(:games, {:game, game_id}, self()) + + :binbo.start() + {:ok, binbo_pid} = :binbo.new_server() + :binbo.new_game(binbo_pid, fen) + + binbo_pid + end + + def init([ + %State{ + color: color, + game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id} + } = state + | tail + ]) + when is_nil(color) do + new_state = + case {is_nil(dark_player_id), is_nil(light_player_id)} do + {true, false} -> %State{state | color: :dark} + {false, true} -> %State{state | color: :light} + {_, _} -> %State{state | color: Enum.random([:light, :dark])} + end + + init([new_state | tail]) + end + + def init([ + %State{ + player_session: player_session, + color: color, + client_pid: client_pid, + game: + %Game{ + id: game_id, + fen: fen, + dark_player_id: dark_player_id, + light_player_id: light_player_id + } = game + } = state + | _ + ]) do + maybe_changeset = + case color do + :light -> + if !light_player_id, + do: Game.changeset(game, %{light_player_id: player_session.player_id}) + + :dark -> + if !dark_player_id, + do: Game.changeset(game, %{dark_player_id: player_session.player_id}) + end + + {status, maybe_new_game} = + if maybe_changeset do + maybe_changeset + |> Repo.update() + else + {:undefined, nil} + end + + new_game = + case {status, maybe_new_game} do + {:ok, g} -> g + _ -> game + end + + binbo_pid = initialize_game(game_id, fen) + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + {:ok, + %State{ + state + | binbo_pid: binbo_pid, + color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark) + }} + end + + def init([ + %State{player_session: player_session, color: color, client_pid: client_pid, game: nil} = + state + | _ + ]) do + {:ok, %Game{id: game_id, fen: fen} = game} = + Game.changeset( + %Game{}, + Map.merge( + if(color == :light, + do: %{light_player_id: player_session.player_id}, + else: %{dark_player_id: player_session.player_id} + ), + %{ + fen: @default_fen, + increment_sec: 3, + light_clock_ms: 5 * 60 * 1000, + dark_clock_ms: 5 * 60 * 1000 + } + ) + ) + |> Repo.insert() + + binbo_pid = initialize_game(game_id, fen) + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + {:ok, %State{state | game: game, binbo_pid: binbo_pid}} + end + + def handle_info( + {:new_move, move}, + %State{game: %Game{id: game_id}, client_pid: client_pid, binbo_pid: binbo_pid} = state + ) do + :binbo.move(binbo_pid, move) + + new_state = %State{state | game: Repo.get(Game, game_id)} + send(client_pid, {:send_to_ssh, render_state(new_state)}) + + {:noreply, new_state} + end + + def input( + width, + height, + action, + %State{ + move_from: move_from, + cursor: %{x: cursor_x, y: cursor_y} = cursor, + client_pid: client_pid, + flipped: flipped + } = state + ) do + new_cursor = + case action do + :left -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, -1, Renderer.chess_board_width())} + :right -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, 1, Renderer.chess_board_width())} + :down -> %{y: Utils.wrap_around(cursor_y, 1, Renderer.chess_board_height()), x: cursor_x} + :up -> %{y: Utils.wrap_around(cursor_y, -1, Renderer.chess_board_height()), x: cursor_x} + _ -> cursor + end + + {new_move_from, move_to} = + if action == :return do + coords = {new_cursor.y, new_cursor.x} + + case move_from do + nil -> {coords, nil} + _ -> {nil, coords} + end + else + {move_from, nil} + end + + if move_from && move_to do + attempt_move(move_from, move_to, state) + end + + new_state = %State{ + state + | cursor: new_cursor, + move_from: new_move_from, + highlighted: %{ + {new_cursor.y, new_cursor.x} => Renderer.to_select_background(), + new_move_from => Renderer.from_select_background() + }, + width: width, + height: height, + flipped: if(action == "f", do: !flipped, else: flipped) + } + + send(client_pid, {:send_to_ssh, render_state(new_state)}) + new_state + end + + def render(width, height, %State{client_pid: client_pid} = state) do + new_state = %State{state | width: width, height: height} + send(client_pid, {:send_to_ssh, render_state(new_state)}) + new_state + end + + defp attempt_move( + from, + to, + %State{ + game: %Game{id: game_id, turn: turn}, + binbo_pid: binbo_pid, + flipped: flipped, + color: turn + } + ) do + attempted_move = + if flipped, + do: "#{Renderer.to_chess_coord(flip(from))}#{Renderer.to_chess_coord(flip(to))}", + else: "#{Renderer.to_chess_coord(from)}#{Renderer.to_chess_coord(to)}" + + case :binbo.move(binbo_pid, attempted_move) do + {:ok, :continue} -> + {:ok, fen} = :binbo.get_fen(binbo_pid) + game = Repo.get(Game, game_id) + + {:ok, _new_game} = + Game.changeset(game, %{ + fen: fen, + moves: game.moves + 1, + turn: if(game.turn == :dark, do: :light, else: :dark), + last_move: DateTime.utc_now() + }) + |> Repo.update() + + :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) + + _ -> + nil + end + end + + defp attempt_move(_, _, _) do + Logger.debug("No matching clause for move attempt - must be illegal?") + nil + end + + defp flip({y, x}), + do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x} + + defp render_state( + %State{ + game: %Game{fen: fen} + } = state + ) do + Renderer.render_board_state(fen, state) + end +end diff --git a/lib/chessh/ssh/client/board/renderer.ex b/lib/chessh/ssh/client/game/renderer.ex similarity index 98% rename from lib/chessh/ssh/client/board/renderer.ex rename to lib/chessh/ssh/client/game/renderer.ex index fa8648f..7652c11 100644 --- a/lib/chessh/ssh/client/board/renderer.ex +++ b/lib/chessh/ssh/client/game/renderer.ex @@ -1,7 +1,7 @@ -defmodule Chessh.SSH.Client.Board.Renderer do +defmodule Chessh.SSH.Client.Game.Renderer do alias IO.ANSI alias Chessh.Utils - alias Chessh.SSH.Client.Board + alias Chessh.SSH.Client.Game require Logger @chess_board_height 8 @@ -25,7 +25,7 @@ defmodule Chessh.SSH.Client.Board.Renderer do "#{List.to_string([?a + x])}#{@chess_board_height - y}" end - def render_board_state(fen, %Board.State{ + def render_board_state(fen, %Game.State{ width: _width, height: _height, highlighted: highlighted, diff --git a/lib/chessh/ssh/client/menu.ex b/lib/chessh/ssh/client/menu.ex index 946928e..9082f67 100644 --- a/lib/chessh/ssh/client/menu.ex +++ b/lib/chessh/ssh/client/menu.ex @@ -1,12 +1,15 @@ defmodule Chessh.SSH.Client.Menu do - alias Chessh.Utils alias IO.ANSI + alias Chessh.{Utils, Repo, Game} + import Ecto.Query require Logger defmodule State do defstruct client_pid: nil, - selected: 0 + selected: 0, + player_session: nil, + options: [] end @logo " Simponic's @@ -20,31 +23,70 @@ defmodule Chessh.SSH.Client.Menu do use Chessh.SSH.Client.Screen def init([%State{} = state | _]) do - {:ok, state} + {:ok, %State{state | options: options(state)}} end - @options [ - {"Start A Game", {Chessh.SSH.Client.Board, %Chessh.SSH.Client.Board.State{}}}, - {"Join A Game", {}}, - {"My Games", {}}, - {"Settings", {}}, - {"Help", {}} - ] + def options(%State{player_session: player_session}) do + current_games = + Repo.all( + from(g in Game, + where: g.light_player_id == ^player_session.player_id, + or_where: g.dark_player_id == ^player_session.player_id, + where: g.status == :continue + ) + ) - def input(width, height, action, %State{client_pid: client_pid, selected: selected} = state) do + joinable_games = + Repo.all( + from(g in Game, + where: is_nil(g.light_player_id), + or_where: is_nil(g.dark_player_id) + ) + ) + + [ + {"Start A Game As Light", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}}, + {"Start A Game As Dark", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}} + ] ++ + Enum.map(current_games, fn game -> + {"Current Game - #{game.id}", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}} + end) ++ + Enum.map(joinable_games, fn game -> + {"Joinable Game - #{game.id}", + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}} + end) ++ + [ + {"Settings", {}}, + {"Help", {}} + ] + end + + def input( + width, + height, + action, + %State{options: options, client_pid: client_pid, selected: selected} = state + ) do new_state = case(action) do :up -> %State{ state - | selected: Utils.wrap_around(selected, -1, length(@options)) + | selected: Utils.wrap_around(selected, -1, length(options)) } :down -> - %State{state | selected: Utils.wrap_around(selected, 1, length(@options))} + %State{state | selected: Utils.wrap_around(selected, 1, length(options))} :return -> - {_option, {module, state}} = Enum.at(@options, selected) + {_option, {module, state}} = Enum.at(options, selected) send(client_pid, {:set_screen_process, module, state}) state @@ -67,7 +109,7 @@ defmodule Chessh.SSH.Client.Menu do defp render_state( width, height, - %State{selected: selected} + %State{options: options, selected: selected} = state ) do logo_lines = String.split(@logo, "\n") {logo_width, logo_height} = Utils.text_dim(@logo) @@ -84,7 +126,7 @@ defmodule Chessh.SSH.Client.Menu do end ) ++ Enum.flat_map( - Enum.zip(0..(length(@options) - 1), @options), + Enum.zip(0..(length(options) - 1), options), fn {i, {option, _}} -> [ ANSI.cursor(y + length(logo_lines) + i + 1, x), diff --git a/lib/chessh/ssh/tui.ex b/lib/chessh/ssh/tui.ex index 1838fb8..483d882 100644 --- a/lib/chessh/ssh/tui.ex +++ b/lib/chessh/ssh/tui.ex @@ -144,7 +144,6 @@ defmodule Chessh.SSH.Tui do {:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}}, %{width: width, height: height, player_session: player_session} = state ) do - Logger.debug("Session #{player_session.id} requested shell") :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) {:ok, client_pid} = @@ -161,47 +160,6 @@ defmodule Chessh.SSH.Tui do {:ok, %State{state | client_pid: client_pid}} end - def handle_ssh_msg( - {:ssh_cm, connection_handler, {:exec, channel_id, want_reply?, cmd}}, - %State{} = state - ) do - :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) - Logger.debug("EXEC #{cmd}") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:eof, _channel_id}}, - %State{} = state - ) do - Logger.debug("EOF") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:signal, _channel_id, signal}}, - %State{} = state - ) do - Logger.debug("SIGNAL #{signal}") - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:exit_signal, channel_id, signal, err, lang}}, - %State{} = state - ) do - Logger.debug("EXIT SIGNAL #{signal} #{err} #{lang}") - {:stop, channel_id, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:exit_STATUS, channel_id, status}}, - %State{} = state - ) do - Logger.debug("EXIT STATUS #{status}") - {:stop, channel_id, state} - end - def handle_ssh_msg( msg, %State{channel_id: channel_id} = state diff --git a/priv/repo/migrations/20230115201050_create_games.exs b/priv/repo/migrations/20230115201050_create_games.exs new file mode 100644 index 0000000..2ed13cb --- /dev/null +++ b/priv/repo/migrations/20230115201050_create_games.exs @@ -0,0 +1,24 @@ +defmodule Chessh.Repo.Migrations.CreateGames do + use Ecto.Migration + + def change do + create table(:games) do + add(:increment_sec, :integer) + add(:light_clock_ms, :integer) + add(:dark_clock_ms, :integer) + add(:last_move, :utc_datetime_usec, null: true) + + add(:fen, :string) + add(:moves, :integer, default: 0) + + add(:turn, :string) + add(:winner, :string) + add(:status, :string) + + add(:light_player_id, references(:players), null: true) + add(:dark_player_id, references(:players), null: true) + + timestamps() + end + end +end