diff --git a/config/config.exs b/config/config.exs
index 11c0a93..aa2f7b1 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -7,6 +7,7 @@ config :chessh,
ascii_chars_json_file: Path.join(Path.dirname(__DIR__), "priv/ascii_chars.json")
config :chessh, RateLimits,
+ player_bots: 2,
jail_timeout_ms: 5 * 60 * 1000,
jail_attempt_threshold: 15,
max_concurrent_user_sessions: 5,
@@ -16,7 +17,9 @@ config :chessh, RateLimits,
create_game_ms: 60 * 1000,
create_game_rate: 3,
discord_notification_rate: 30,
- discord_notification_rate_ms: 1000
+ discord_notification_rate_ms: 1000,
+ bot_redrive_rate: 1,
+ bot_redrive_rate_ms: 60_000
config :chessh, Web,
discord_oauth_login_url: "https://discord.com/api/oauth2/token",
diff --git a/front/src/index.css b/front/src/index.css
index f9c4620..fc17142 100644
--- a/front/src/index.css
+++ b/front/src/index.css
@@ -69,7 +69,7 @@ body {
.navbar {
display: flex;
flex-direction: row;
- justify-content: space-between;
+ justify-content: center;
align-items: center;
margin-bottom: 1rem;
diff --git a/front/src/index.js b/front/src/index.js
index eb2801c..fdb7b5a 100644
--- a/front/src/index.js
+++ b/front/src/index.js
@@ -7,6 +7,7 @@ import { Root } from "./root";
import { Demo } from "./routes/demo";
import { Home } from "./routes/home";
import { Keys } from "./routes/keys";
+import { Bots } from "./routes/bots";
import { ManPages } from "./routes/man_pages";
import { Password } from "./routes/password";
import { AuthSuccessful } from "./routes/auth_successful";
@@ -36,6 +37,10 @@ const router = createBrowserRouter([
path: "auth-successful",
element:
Bot Name *
+ setName(e.target.value)} + required + /> +Webhook *
+ setWebhook(e.target.value)} + required + /> ++ Public *{" "} + setIsPublic(!isPublic)} + required + /> +
+{error}
+ ))} +Loading...
; + + return ( + <> +Looks like you've got no bots, try adding one!
+ )} +Loading...
; + if (keys === null) returnLoading...
; if (Array.isArray(keys)) { return ( @@ -192,7 +190,7 @@ export const Keys = () => {Looks like you've got no keys, try adding some!
diff --git a/lib/chessh/schema/bot.ex b/lib/chessh/schema/bot.ex new file mode 100644 index 0000000..6a6d15b --- /dev/null +++ b/lib/chessh/schema/bot.ex @@ -0,0 +1,79 @@ +defmodule Chessh.Bot do + alias Chessh.{Player, Game, Repo} + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + + require Logger + + @derive {Jason.Encoder, only: [:id, :name, :webhook, :token, :public]} + schema "bots" do + field(:name, :string) + field(:webhook, :string) + field(:token, :string) + field(:public, :boolean, default: false) + + belongs_to(:player, Player, foreign_key: :player_id) + + timestamps() + end + + def changeset(game, attrs) do + game + |> cast(attrs, [ + :public, + :name, + :webhook, + :token, + :player_id + ]) + |> validate_required([:name, :webhook, :token, :public]) + |> validate_format(:webhook, ~r/^https:\/\//, message: "must start with https://") + |> unique_constraint(:name) + end + + def make_game_status_message(%Game{ + id: game_id, + bot: %Chessh.Bot{id: bot_id, name: bot_name}, + fen: fen, + turn: turn, + last_move: last_move, + status: status, + light_player_id: light_player_id, + dark_player_id: dark_player_id + }) do + %{ + bot_id: bot_id, + bot_name: bot_name, + game_id: game_id, + fen: fen, + turn: Atom.to_string(turn), + bot_turn: + (is_nil(light_player_id) && turn == :light) || (is_nil(dark_player_id) && turn == :dark), + last_move: last_move, + status: Atom.to_string(status) + } + end + + def redrive_games(%Chessh.Bot{id: bot_id, webhook: webhook}) do + messages = + Repo.all(from(g in Game, where: g.bot_id == ^bot_id)) + |> Repo.preload([:bot]) + |> Enum.map(&make_game_status_message/1) + + send_message(webhook, messages) + end + + def send_update(%Game{bot: %Chessh.Bot{webhook: webhook}} = game) do + send_message(webhook, make_game_status_message(game)) + end + + defp send_message(webhook, msg) do + :httpc.request( + :post, + {String.to_charlist(webhook), [], 'application/json', Jason.encode!(msg)}, + [], + [] + ) + end +end diff --git a/lib/chessh/schema/game.ex b/lib/chessh/schema/game.ex index 868fec8..8777472 100644 --- a/lib/chessh/schema/game.ex +++ b/lib/chessh/schema/game.ex @@ -1,8 +1,10 @@ defmodule Chessh.Game do - alias Chessh.Player + alias Chessh.{Bot, Player, Game} use Ecto.Schema import Ecto.Changeset + @default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + schema "games" do field(:fen, :string) field(:moves, :integer, default: 0) @@ -17,6 +19,8 @@ defmodule Chessh.Game do belongs_to(:light_player, Player, foreign_key: :light_player_id) belongs_to(:dark_player, Player, foreign_key: :dark_player_id) + belongs_to(:bot, Bot, foreign_key: :bot_id) + field(:discord_thread_id, :string) timestamps() @@ -34,7 +38,51 @@ defmodule Chessh.Game do :light_player_id, :dark_player_id, :discord_thread_id, + :bot_id, :game_moves ]) end + + def new_game(initial_player_color, player_id, fen \\ @default_fen) do + Game.changeset( + %Game{ + fen: fen + }, + if(initial_player_color == :light, + do: %{light_player_id: player_id}, + else: %{dark_player_id: player_id} + ) + ) + end + + def update_with_status(%Game{} = game, move, fen, status) do + Game.changeset( + game, + %{ + fen: fen, + moves: game.moves + 1, + turn: if(game.turn == :dark, do: :light, else: :dark), + last_move: move, + game_moves: + if(!is_nil(game) && game.game_moves != "", do: "#{game.game_moves} ", else: "") <> move + } + |> Map.merge(changeset_from_status(status)) + ) + end + + def changeset_from_status(game_status) do + case game_status do + :continue -> + %{} + + {:draw, _} -> + %{status: :draw} + + {:checkmate, :white_wins} -> + %{status: :winner, winner: :light} + + {:checkmate, :black_wins} -> + %{status: :winner, winner: :dark} + end + end end diff --git a/lib/chessh/schema/player.ex b/lib/chessh/schema/player.ex index dcb3548..d5bcab2 100644 --- a/lib/chessh/schema/player.ex +++ b/lib/chessh/schema/player.ex @@ -1,7 +1,7 @@ defmodule Chessh.Player do use Ecto.Schema import Ecto.Changeset - alias Chessh.{Key, Game} + alias Chessh.{Key, Game, Bot} @derive {Inspect, except: [:password]} schema "players" do @@ -17,6 +17,7 @@ defmodule Chessh.Player do 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) + has_many(:bots, Bot, foreign_key: :player_id, references: :id) timestamps() end diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex index 882a90e..5a0ea1e 100644 --- a/lib/chessh/ssh/client/client.ex +++ b/lib/chessh/ssh/client/client.ex @@ -156,8 +156,8 @@ defmodule Chessh.SSH.Client do # C-b <<2>> -> :menu # Escape - "\e" -> :menu - # VIM keys, per request + "\e" -> :escape + # VIM keys "k" -> :up "j" -> :down "h" -> :left diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex index 4fb28f3..a05b9f1 100644 --- a/lib/chessh/ssh/client/game/game.ex +++ b/lib/chessh/ssh/client/game/game.ex @@ -1,10 +1,8 @@ defmodule Chessh.SSH.Client.Game do require Logger - alias Chessh.{Game, Utils, Repo} + alias Chessh.{Game, Utils, Repo, Bot} 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: %{}, @@ -69,27 +67,14 @@ defmodule Chessh.SSH.Client.Game do case Hammer.check_rate_inc( :redis, - "player-#{state.player_session.id}-create-game-rate", + "player-#{state.player_session.player_id}-create-game-rate", create_game_ms, create_game_rate, 1 ) do {:allow, _count} -> - # Starting a new game - {:ok, %Game{id: game_id} = 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 - } - ) - ) - |> Repo.insert() + game = Game.new_game(color, player_session.player_id) |> Repo.insert!() + %Game{id: game_id} = game GenServer.cast( :discord_notifier, @@ -129,20 +114,23 @@ defmodule Chessh.SSH.Client.Game do id: game_id, fen: fen, dark_player_id: dark_player_id, - light_player_id: light_player_id + light_player_id: light_player_id, + bot_id: bot_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}) + if !bot_id do + 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}) + :dark -> + if !dark_player_id, + do: Game.changeset(game, %{dark_player_id: player_session.player_id}) + end end {status, maybe_joined_game} = @@ -164,7 +152,7 @@ defmodule Chessh.SSH.Client.Game do end binbo_pid = initialize_game(game_id, fen) - game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot]) player_color = if(game.light_player_id == player_session.player_id, do: :light, else: :dark) @@ -206,7 +194,7 @@ defmodule Chessh.SSH.Client.Game do } end).(%State{ state - | game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + | game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot]) }) send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)}) @@ -218,7 +206,7 @@ defmodule Chessh.SSH.Client.Game do :player_joined, %State{client_pid: client_pid, game: %Game{id: game_id}} = state ) do - game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) + game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot]) new_state = %State{state | game: game} send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)}) {:noreply, new_state} @@ -391,22 +379,15 @@ defmodule Chessh.SSH.Client.Game do {:ok, status} -> {:ok, fen} = :binbo.get_fen(binbo_pid) - {:ok, %Game{status: after_move_status}} = + {:ok, %Game{status: after_move_status} = game} = game - |> Game.changeset( - Map.merge( - %{ - fen: fen, - moves: game.moves + 1, - turn: if(game.turn == :dark, do: :light, else: :dark), - last_move: attempted_move, - game_moves: if(game_moves, do: game_moves <> " ", else: "") <> attempted_move - }, - changeset_from_status(status) - ) - ) + |> Game.update_with_status(attempted_move, fen, status) |> Repo.update() + if !is_nil(game.bot) do + spawn(fn -> Bot.send_update(Repo.get(Game, game.id) |> Repo.preload([:bot])) end) + end + :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) if after_move_status == :continue do @@ -433,22 +414,6 @@ defmodule Chessh.SSH.Client.Game do nil end - defp changeset_from_status(game_status) do - case game_status do - :continue -> - %{} - - {:draw, _} -> - %{status: :draw} - - {:checkmate, :white_wins} -> - %{status: :winner, winner: :light} - - {:checkmate, :black_wins} -> - %{status: :winner, winner: :dark} - end - end - defp make_highlight_map( %State{ game: %Game{last_move: last_move, turn: turn}, diff --git a/lib/chessh/ssh/client/game/renderer.ex b/lib/chessh/ssh/client/game/renderer.ex index b9071a9..cffc838 100644 --- a/lib/chessh/ssh/client/game/renderer.ex +++ b/lib/chessh/ssh/client/game/renderer.ex @@ -2,6 +2,7 @@ defmodule Chessh.SSH.Client.Game.Renderer do alias IO.ANSI alias Chessh.{Utils, Player} alias Chessh.SSH.Client.Game + require Logger @chess_board_height 8 @chess_board_width 8 @@ -42,29 +43,17 @@ defmodule Chessh.SSH.Client.Game.Renderer do %Game.State{ game: %Chessh.Game{ - light_player: light_player - } = game - } = state - ) - when is_nil(light_player) do - render_board_state(%Game.State{ - state - | game: %Chessh.Game{game | light_player: %Player{username: "(no opponent)"}} - }) - end - - def render_board_state( - %Game.State{ - game: - %Chessh.Game{ + light_player: light_player, dark_player: dark_player } = game } = state ) - when is_nil(dark_player) do + when is_nil(light_player) or is_nil(dark_player) do + {light_player, dark_player} = get_players(game) + render_board_state(%Game.State{ state - | game: %Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}} + | game: %Chessh.Game{game | light_player: light_player, dark_player: dark_player} }) end @@ -73,9 +62,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do flipped: flipped, game: %Chessh.Game{ - fen: fen + fen: fen, + light_player: light_player, + dark_player: dark_player } = game - }) do + }) + when not is_nil(light_player) and not is_nil(dark_player) do rendered = [ ANSI.clear_line(), make_status_line(game, true) @@ -98,29 +90,19 @@ defmodule Chessh.SSH.Client.Game.Renderer do def make_status_line( %Chessh.Game{ - light_player: light_player - } = game, - fancy - ) - when is_nil(light_player), - do: - make_status_line( - %Chessh.Game{game | light_player: %Player{username: "(no opponent)"}}, - fancy - ) - - def make_status_line( - %Chessh.Game{ + light_player: light_player, dark_player: dark_player } = game, fancy ) - when is_nil(dark_player), - do: - make_status_line( - %Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}}, - fancy - ) + when is_nil(light_player) or is_nil(dark_player) do + {light_player, dark_player} = get_players(game) + + make_status_line( + %Chessh.Game{game | light_player: light_player, dark_player: dark_player}, + fancy + ) + end def make_status_line( %Chessh.Game{ @@ -143,12 +125,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do "Game #{game_id} - ", if(fancy, do: ANSI.format_fragment([@light_piece_color, light_player]), - else: "#{light_player} (L)" + else: "♔ #{light_player}" ), "#{if fancy, do: ANSI.default_color(), else: ""} --vs-- ", if(fancy, do: ANSI.format_fragment([@dark_piece_color, dark_player]), - else: "#{dark_player} (D)" + else: "♚ #{dark_player}" ), if(fancy, do: ANSI.default_color(), else: ""), case status do @@ -373,4 +355,25 @@ defmodule Chessh.SSH.Client.Game.Renderer do Map.merge(acc, pieces_map_for_this_row) end) end + + defp get_players( + %Chessh.Game{light_player: light_player, dark_player: dark_player, bot: bot} = game + ) do + case {is_nil(light_player), is_nil(dark_player), is_nil(bot)} do + {false, true, false} -> + {game.light_player, %Player{username: bot.name}} + + {true, false, false} -> + {%Player{username: bot.name}, game.dark_player} + + {true, false, true} -> + {%Player{username: "(no opponent)"}, game.dark_player} + + {false, true, true} -> + {game.light_player, %Player{username: "(no opponent)"}} + + {false, false, true} -> + {game.light_player, game.dark_player} + end + end end diff --git a/lib/chessh/ssh/client/menus/create_game.ex b/lib/chessh/ssh/client/menus/create_game.ex new file mode 100644 index 0000000..99d2c0e --- /dev/null +++ b/lib/chessh/ssh/client/menus/create_game.ex @@ -0,0 +1,42 @@ +defmodule Chessh.SSH.Client.CreateGameMenu do + alias IO.ANSI + + alias Chessh.PlayerSession + alias Chessh.SSH.Client.Game + + require Logger + + use Chessh.SSH.Client.SelectPaginatePoller + + def dynamic_options(), do: false + def tick_delay_ms(), do: 1000 + def max_displayed_options(), do: 4 + def title(), do: ["-- Create A New Game --"] + + def initial_options(%State{player_session: %PlayerSession{} = player_session}) do + [ + {"😀 vs 😀 | ⬜ White", {Game, %Game.State{player_session: player_session, color: :light}}}, + {"😀 vs 😀 | ⬛ Black", {Game, %Game.State{player_session: player_session, color: :dark}}}, + {"😀 vs 🤖 | ⬜ White", + {Chessh.SSH.Client.SelectBot, + %Chessh.SSH.Client.SelectPaginatePoller.State{ + player_session: player_session, + extra_info: %{ + color: :light + } + }}}, + {"🤖 vs 😀 | ⬛ Black", + {Chessh.SSH.Client.SelectBot, + %Chessh.SSH.Client.SelectPaginatePoller.State{ + player_session: player_session, + extra_info: %{ + color: :dark + } + }}} + ] + end + + def make_process_tuple(selected, _state) do + selected + end +end diff --git a/lib/chessh/ssh/client/menus/main_menu.ex b/lib/chessh/ssh/client/menus/main_menu.ex index ee4b976..7b83b76 100644 --- a/lib/chessh/ssh/client/menus/main_menu.ex +++ b/lib/chessh/ssh/client/menus/main_menu.ex @@ -1,6 +1,5 @@ defmodule Chessh.SSH.Client.MainMenu do alias IO.ANSI - alias Chessh.PlayerSession require Logger @@ -22,9 +21,12 @@ defmodule Chessh.SSH.Client.MainMenu do def max_box_cols(), do: @logo_cols def title(), do: @logo ++ ["- Connected on: #{System.get_env("NODE_ID")}"] - def initial_options(%State{player_session: %PlayerSession{} = player_session}) do + def initial_options(%State{player_session: player_session}) do [ - {"My Current Games", + {"Create Game", + {Chessh.SSH.Client.CreateGameMenu, + %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, + {"Current Games", {Chessh.SSH.Client.SelectCurrentGame, %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, {"Joinable Games (lobby)", @@ -32,13 +34,7 @@ defmodule Chessh.SSH.Client.MainMenu do %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, {"Previous Games", {Chessh.SSH.Client.SelectPreviousGame, - %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, - {"Start A Game (Light)", - {Chessh.SSH.Client.Game, - %Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}}, - {"Start A Game (Dark)", - {Chessh.SSH.Client.Game, - %Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}} + %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}} ] end diff --git a/lib/chessh/ssh/client/menus/select_bot.ex b/lib/chessh/ssh/client/menus/select_bot.ex new file mode 100644 index 0000000..f31f65a --- /dev/null +++ b/lib/chessh/ssh/client/menus/select_bot.ex @@ -0,0 +1,117 @@ +defmodule Chessh.SSH.Client.SelectBot do + alias Chessh.{Utils, Bot, Repo, Game} + alias Chessh.SSH.Client.Selector + import Ecto.Query + require Logger + + use Chessh.SSH.Client.SelectPaginatePoller + + def refresh_options_ms(), do: 4000 + def max_displayed_options(), do: 5 + def title(), do: ["-- Select Bot To Play Against --"] + def dynamic_options(), do: true + + def get_bots(player_id, current_id \\ nil, direction \\ :desc) do + Selector.paginate_ish_query( + Bot + |> where([b], b.player_id == ^player_id or b.public == true) + |> limit(^max_displayed_options()), + current_id, + direction + ) + end + + def format_bot_tuple(%Bot{id: id, name: name}), do: {name, id} + + def next_page_options(%State{ + options: options, + player_session: %PlayerSession{ + player_id: player_id + } + }) do + {_label, previous_last_bot_id} = List.last(options) + next_bots = get_bots(player_id, previous_last_bot_id, :desc) + + if length(next_bots) > 0, + do: + next_bots + |> Enum.map(&format_bot_tuple/1), + else: options + end + + def previous_page_options(%State{ + options: options, + player_session: %PlayerSession{player_id: player_id} + }) do + {_label, previous_first_bot_id} = List.first(options) + + previous_bots = get_bots(player_id, previous_first_bot_id, :asc) + + if length(previous_bots) > 0, + do: + previous_bots + |> Enum.map(&format_bot_tuple/1), + else: options + end + + def initial_options(%State{ + player_session: %PlayerSession{player_id: player_id} + }) do + get_bots(player_id) + |> Enum.map(&format_bot_tuple/1) + end + + def refresh_options(%State{ + options: options, + player_session: %PlayerSession{player_id: player_id} + }) do + previous_last_bot_id = + case List.last(options) do + {_name, id} -> id + _ -> 1 + end + + current_screen_games = get_bots(player_id, previous_last_bot_id - 1, :asc) + + if !is_nil(current_screen_games) && length(current_screen_games), + do: + current_screen_games + |> Enum.map(&format_bot_tuple/1), + else: options + end + + def make_process_tuple(selected_id, %State{ + player_session: player_session, + extra_info: %{color: color} + }) do + [create_game_ms, create_game_rate] = + Application.get_env(:chessh, RateLimits) + |> Keyword.take([:create_game_ms, :create_game_rate]) + |> Keyword.values() + + case Hammer.check_rate_inc( + :redis, + "player-#{player_session.player_id}-create-game-rate", + create_game_ms, + create_game_rate, + 1 + ) do + {:allow, _count} -> + {:ok, game} = + Game.changeset( + Game.new_game(color, player_session.player_id), + %{ + bot_id: selected_id + } + ) + |> Repo.insert() + + {Chessh.SSH.Client.Game, + %Chessh.SSH.Client.Game.State{player_session: player_session, color: color, game: game}} + + {:deny, _limit} -> + {Chessh.SSH.Client.MainMenu, + %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}} + end + end +end diff --git a/lib/chessh/ssh/client/menus/select_current_game.ex b/lib/chessh/ssh/client/menus/select_current_game.ex index 3c47b15..7c49a30 100644 --- a/lib/chessh/ssh/client/menus/select_current_game.ex +++ b/lib/chessh/ssh/client/menus/select_current_game.ex @@ -1,6 +1,6 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do alias Chessh.{Utils, Repo, Game, PlayerSession} - alias Chessh.SSH.Client.GameSelector + alias Chessh.SSH.Client.Selector import Ecto.Query require Logger @@ -12,7 +12,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do def dynamic_options(), do: true def get_player_sorted_current_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do - GameSelector.paginate_ish_query( + Selector.paginate_ish_query( Game |> where([g], g.status == :continue) |> where([g], g.light_player_id == ^player_id or g.dark_player_id == ^player_id) @@ -20,6 +20,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do current_id, direction ) + |> Repo.preload([:light_player, :dark_player, :bot]) end def format_game_selection_tuple(%Game{id: game_id} = game) do @@ -67,7 +68,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do order_by: [desc: g.id] ) |> Repo.all() - |> Repo.preload([:light_player, :dark_player]) + |> Repo.preload([:light_player, :dark_player, :bot]) |> Enum.map(&format_game_selection_tuple/1) end @@ -78,7 +79,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do previous_last_game_id = case List.last(options) do {_label, id} -> id - _ -> 0 + _ -> 1 end current_screen_games = diff --git a/lib/chessh/ssh/client/menus/select_joinable_game.ex b/lib/chessh/ssh/client/menus/select_joinable_game.ex index f2b7b1f..b0d0cbe 100644 --- a/lib/chessh/ssh/client/menus/select_joinable_game.ex +++ b/lib/chessh/ssh/client/menus/select_joinable_game.ex @@ -1,7 +1,8 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do alias Chessh.{Utils, Repo, Game, PlayerSession} - alias Chessh.SSH.Client.GameSelector + alias Chessh.SSH.Client.Selector import Ecto.Query + require Logger use Chessh.SSH.Client.SelectPaginatePoller @@ -12,18 +13,20 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do def dynamic_options(), do: true def get_player_joinable_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do - GameSelector.paginate_ish_query( + Selector.paginate_ish_query( Game |> where([g], g.status == :continue) |> where( [g], (is_nil(g.dark_player_id) or is_nil(g.light_player_id)) and - (g.dark_player_id != ^player_id or g.light_player_id != ^player_id) + (g.dark_player_id != ^player_id or g.light_player_id != ^player_id) and + is_nil(g.bot_id) ) |> limit(^max_displayed_options()), current_id, direction ) + |> Repo.preload([:light_player, :dark_player, :bot]) end def format_game_selection_tuple(%Game{id: game_id} = game) do @@ -60,6 +63,8 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do end def initial_options(%State{player_session: %PlayerSession{player_id: player_id}}) do + Logger.info(player_id) + get_player_joinable_games_with_id(player_id) |> Enum.map(&format_game_selection_tuple/1) end @@ -71,7 +76,7 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do previous_last_game_id = case List.last(options) do {_label, id} -> id - _ -> 0 + _ -> 1 end current_screen_games = diff --git a/lib/chessh/ssh/client/menus/select_paginate_poller.ex b/lib/chessh/ssh/client/menus/select_paginate_poller.ex index adca697..23808b5 100644 --- a/lib/chessh/ssh/client/menus/select_paginate_poller.ex +++ b/lib/chessh/ssh/client/menus/select_paginate_poller.ex @@ -26,7 +26,8 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do player_session: nil, options: [], tick: 0, - cursor: nil + cursor: nil, + extra_info: %{} end defmacro __using__(_) do @@ -127,51 +128,55 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do max_item = min(length(options), max_displayed_options()) new_state = - if(max_item > 0, - do: - case action do - :up -> - %State{ - state - | selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item), - tick: 0 - } - - :down -> - %State{ - state - | selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item), - tick: 0 - } - - :left -> - if dynamic_options(), - do: %State{ + if max_item > 0 do + if action == :return do + {_, selected} = Enum.at(options, selected_option_idx) + {module, state} = make_process_tuple(selected, state) + send(client_pid, {:set_screen_process, module, state}) + state + else + if(max_item > 1) do + case action do + :up -> + %State{ state - | options: previous_page_options(state), - selected_option_idx: 0, + | selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item), tick: 0 } - :right -> - if dynamic_options(), - do: %State{ + :down -> + %State{ state - | options: next_page_options(state), - selected_option_idx: 0, + | selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item), tick: 0 } - :return -> - {_, selected} = Enum.at(options, selected_option_idx) - {module, state} = make_process_tuple(selected, state) - send(client_pid, {:set_screen_process, module, state}) - state + :left -> + if dynamic_options(), + do: %State{ + state + | options: previous_page_options(state), + selected_option_idx: 0, + tick: 0 + } - _ -> - nil + :right -> + if dynamic_options(), + do: %State{ + state + | options: next_page_options(state), + selected_option_idx: 0, + tick: 0 + } + + _ -> + state + end + else + state end - ) || state + end + end if !(action == :return) do render(width, height, new_state) @@ -229,7 +234,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do if i == selected_option_idx do ANSI.format_fragment( - [:light_cyan, :bright, "> #{line} <", :reset], + [:light_cyan, :bright, "♜ #{line} ♜", :reset], true ) else @@ -238,7 +243,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do end ) else - ["Looks like there's nothing here.", "Use Ctrl+b to go back."] + ["Looks like there's nothing here.", "Use Ctrl+b return to the menu."] end end diff --git a/lib/chessh/ssh/client/menus/select_previous_game.ex b/lib/chessh/ssh/client/menus/select_previous_game.ex index 5f55c3d..c348268 100644 --- a/lib/chessh/ssh/client/menus/select_previous_game.ex +++ b/lib/chessh/ssh/client/menus/select_previous_game.ex @@ -1,6 +1,6 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do alias Chessh.{Utils, Repo, Game, PlayerSession} - alias Chessh.SSH.Client.GameSelector + alias Chessh.SSH.Client.Selector import Ecto.Query require Logger @@ -12,7 +12,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do def dynamic_options(), do: true def get_player_sorted_current_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do - GameSelector.paginate_ish_query( + Selector.paginate_ish_query( Game |> where([g], g.status != :continue) |> where([g], g.light_player_id == ^player_id or g.dark_player_id == ^player_id) @@ -20,6 +20,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do current_id, direction ) + |> Repo.preload([:light_player, :dark_player, :bot]) end def format_game_selection_tuple(%Game{id: game_id} = game) do @@ -67,7 +68,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do order_by: [desc: g.id] ) |> Repo.all() - |> Repo.preload([:light_player, :dark_player]) + |> Repo.preload([:light_player, :dark_player, :bot]) |> Enum.map(&format_game_selection_tuple/1) end @@ -78,7 +79,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do previous_last_game_id = case List.last(options) do {_label, id} -> id - _ -> 0 + _ -> 1 end current_screen_games = diff --git a/lib/chessh/ssh/client/menus/game_selector.ex b/lib/chessh/ssh/client/menus/selector.ex similarity index 86% rename from lib/chessh/ssh/client/menus/game_selector.ex rename to lib/chessh/ssh/client/menus/selector.ex index 7792082..225c41f 100644 --- a/lib/chessh/ssh/client/menus/game_selector.ex +++ b/lib/chessh/ssh/client/menus/selector.ex @@ -1,4 +1,4 @@ -defmodule Chessh.SSH.Client.GameSelector do +defmodule Chessh.SSH.Client.Selector do import Ecto.Query alias Chessh.Repo @@ -17,7 +17,6 @@ defmodule Chessh.SSH.Client.GameSelector do sorted_query end |> Repo.all() - |> Repo.preload([:light_player, :dark_player]) if direction == :desc, do: results, else: Enum.reverse(results) end diff --git a/lib/chessh/utils.ex b/lib/chessh/utils.ex index 20a8b96..1900793 100644 --- a/lib/chessh/utils.ex +++ b/lib/chessh/utils.ex @@ -1,4 +1,6 @@ defmodule Chessh.Utils do + require Logger + @ascii_chars Application.compile_env!(:chessh, :ascii_chars_json_file) |> File.read!() |> Jason.decode!() @@ -35,4 +37,8 @@ defmodule Chessh.Utils do calc = index + delta if(calc < 0, do: length, else: 0) + rem(calc, length) end + + def random_token() do + :crypto.strong_rand_bytes(16) |> Base.encode16() + end end diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex index 8cddd32..78b8ff9 100644 --- a/lib/chessh/web/web.ex +++ b/lib/chessh/web/web.ex @@ -1,5 +1,5 @@ defmodule Chessh.Web.Endpoint do - alias Chessh.{Player, Repo, Key, PlayerSession} + alias Chessh.{Player, Repo, Key, PlayerSession, Bot, Utils, Game} alias Chessh.Web.Token use Plug.Router import Ecto.Query @@ -108,7 +108,7 @@ defmodule Chessh.Web.Endpoint do {status, body} = case conn.body_params do %{"key" => key, "name" => name} -> - if player_key_count > max_key_count do + if player_key_count >= max_key_count do {400, %{errors: "Player has reached threshold of #{max_key_count} keys."}} else case Key.changeset(%Key{player_id: player.id}, %{key: key, name: name}) @@ -164,7 +164,7 @@ defmodule Chessh.Web.Endpoint do |> send_resp(200, Jason.encode!(keys)) end - delete "/keys/:id" do + delete "/player/keys/:id" do player = get_player_from_jwt(conn) PlayerSession.close_all_player_sessions(player) @@ -193,6 +193,203 @@ defmodule Chessh.Web.Endpoint do |> send_resp(status, Jason.encode!(body)) end + get "/player/bots" do + player = get_player_from_jwt(conn) + + conn + |> put_resp_content_type("application/json") + |> send_resp( + 200, + Jason.encode!(Repo.all(from(b in Bot, where: b.player_id == ^player.id))) + ) + end + + put "/player/bots/:id" do + player = get_player_from_jwt(conn) + bot = Repo.get(Bot, conn.path_params["id"]) + + {status, body} = + if player.id != bot.player_id do + {403, %{errors: "Player cannot edit that bot."}} + else + case conn.body_params do + %{"webhook" => webhook, "name" => name, "public" => public} -> + case Bot.changeset(bot, %{webhook: webhook, name: name, public: public}) + |> Repo.update() do + {:ok, new_bot} -> + {200, + %{ + success: true, + bot: new_bot + }} + + {:error, %{valid?: false} = changeset} -> + { + 400, + %{ + errors: format_errors(changeset) + } + } + end + + _ -> + {400, %{errors: "webhook, name, publicity must all be specified"}} + end + end + + conn + |> put_resp_content_type("application/json") + |> send_resp( + status, + Jason.encode!(body) + ) + end + + get "/player/bots/:id/redrive" do + player = get_player_from_jwt(conn) + bot = Repo.get(Bot, conn.path_params["id"]) + + [bot_redrive_rate, bot_redrive_rate_ms] = + Application.get_env(:chessh, RateLimits) + |> Keyword.take([ + :bot_redrive_rate, + :bot_redrive_rate_ms + ]) + |> Keyword.values() + + {status, body} = + if player.id == bot.player_id do + case Hammer.check_rate_inc( + :redis, + "bot-#{bot.id}-redrive", + bot_redrive_rate_ms, + bot_redrive_rate, + 1 + ) do + {:allow, _count} -> + spawn(fn -> Bot.redrive_games(bot) end) + {200, %{message: "redrive rescheduled"}} + + {:deny, _} -> + {429, + %{ + message: + "can only redrive #{bot_redrive_rate} time(s) #{bot_redrive_rate_ms} milliseconds" + }} + end + else + {403, %{message: "you can't do that"}} + end + + conn + |> put_resp_content_type("application/json") + |> send_resp( + status, + Jason.encode!(body) + ) + end + + post "/bots/games/:id/turn" do + token = conn.body_params["token"] + attempted_move = conn.body_params["attempted_move"] + + bot = Repo.one(from(b in Bot, where: b.token == ^token)) + game = Repo.get(Game, conn.path_params["id"]) + + {status, body} = + if game.bot_id == bot.id do + if (game.turn == :light && !game.light_player_id) || + (game.turn == :dark && !game.dark_player_id) do + {:ok, binbo_pid} = :binbo.new_server() + :binbo.new_game(binbo_pid, game.fen) + + case :binbo.move(binbo_pid, attempted_move) do + {:ok, status} -> + {:ok, fen} = :binbo.get_fen(binbo_pid) + + {:ok, %Game{status: after_move_status} = game} = + game + |> Game.update_with_status(attempted_move, fen, status) + |> Repo.update() + + :syn.publish(:games, {:game, game.id}, {:new_move, attempted_move}) + + {200, %{message: "success"}} + + _ -> + {400, %{message: "invalid move"}} + end + else + {400, %{message: "not the bot's turn"}} + end + else + {403, %{message: "unauthorized"}} + end + + conn + |> put_resp_content_type("application/json") + |> send_resp( + status, + Jason.encode!(body) + ) + end + + post "/player/bots" do + player = get_player_from_jwt(conn) + + player_bot_count = + Repo.aggregate(from(b in Bot, where: b.player_id == ^player.id), :count, :id) + + max_bot_count = Application.get_env(:chessh, RateLimits)[:player_bots] + bot_token = Utils.random_token() + + {status, body} = + case conn.body_params do + %{"webhook" => webhook, "name" => name, "public" => public} -> + if player_bot_count >= max_bot_count do + {400, %{errors: "Player has reached threshold of #{max_bot_count} bots."}} + else + case Bot.changeset(%Bot{player_id: player.id}, %{ + token: bot_token, + webhook: webhook, + name: name, + public: public + }) + |> Repo.insert() do + {:ok, new_bot} -> + { + 200, + %{ + success: true, + bot: new_bot + } + } + + {:error, %{valid?: false} = changeset} -> + { + 400, + %{ + errors: format_errors(changeset) + } + } + end + end + + _ -> + { + 400, + %{errors: "webhook, name, publicity must all be specified"} + } + end + + conn + |> put_resp_content_type("application/json") + |> send_resp( + status, + Jason.encode!(body) + ) + end + match _ do send_resp(conn, 404, "Route undefined") end diff --git a/priv/repo/migrations/20230529193453_add_citext.exs b/priv/repo/migrations/20230529193453_add_citext.exs new file mode 100644 index 0000000..371df8d --- /dev/null +++ b/priv/repo/migrations/20230529193453_add_citext.exs @@ -0,0 +1,11 @@ +defmodule Chessh.Repo.Migrations.AddCitext do + use Ecto.Migration + + def up do + execute("CREATE EXTENSION citext") + end + + def down do + execute("DROP EXTENSION citext") + end +end diff --git a/priv/repo/migrations/20230529193504_add_bots.exs b/priv/repo/migrations/20230529193504_add_bots.exs new file mode 100644 index 0000000..3f48c0a --- /dev/null +++ b/priv/repo/migrations/20230529193504_add_bots.exs @@ -0,0 +1,22 @@ +defmodule Chessh.Repo.Migrations.AddBots do + use Ecto.Migration + + def change do + create table(:bots) do + add(:name, :citext, null: false) + add(:webhook, :string, null: false) + add(:token, :string, null: false) + add(:public, :boolean, null: false) + add(:player_id, references(:players), null: false) + + timestamps() + end + + create(unique_index(:bots, [:name])) + create(unique_index(:bots, [:token])) + + alter table(:games) do + add(:bot_id, references(:bots), null: true) + end + end +end