From eec32aa38a8762eccc8575a37a628bd5ae2cc1d0 Mon Sep 17 00:00:00 2001 From: "Elizabeth (Lizzy) Hunt" Date: Mon, 29 May 2023 16:28:27 -0700 Subject: [PATCH] Bots (#23) * squash all the things for bots * fix warnings * change colors a bit and README updates * fix frontend warnings --- README.md | 6 + config/config.exs | 5 +- front/src/index.css | 2 +- front/src/index.js | 5 + front/src/root.jsx | 24 ++- front/src/routes/bots.jsx | 194 +++++++++++++++++ front/src/routes/keys.jsx | 10 +- front/src/routes/man_pages.jsx | 6 +- lib/chessh/schema/bot.ex | 77 +++++++ lib/chessh/schema/game.ex | 50 ++++- lib/chessh/schema/player.ex | 3 +- lib/chessh/ssh/client/client.ex | 4 +- lib/chessh/ssh/client/game/game.ex | 85 +++----- lib/chessh/ssh/client/game/renderer.ex | 92 ++++---- lib/chessh/ssh/client/menus/create_game.ex | 42 ++++ lib/chessh/ssh/client/menus/main_menu.ex | 16 +- lib/chessh/ssh/client/menus/select_bot.ex | 119 ++++++++++ .../ssh/client/menus/select_current_game.ex | 9 +- .../ssh/client/menus/select_joinable_game.ex | 13 +- .../client/menus/select_paginate_poller.ex | 81 +++---- .../ssh/client/menus/select_previous_game.ex | 9 +- .../menus/{game_selector.ex => selector.ex} | 3 +- lib/chessh/utils.ex | 6 + lib/chessh/web/web.ex | 203 +++++++++++++++++- .../migrations/20230529193453_add_citext.exs | 11 + .../migrations/20230529193504_add_bots.exs | 22 ++ 26 files changed, 905 insertions(+), 192 deletions(-) create mode 100644 front/src/routes/bots.jsx create mode 100644 lib/chessh/schema/bot.ex create mode 100644 lib/chessh/ssh/client/menus/create_game.ex create mode 100644 lib/chessh/ssh/client/menus/select_bot.ex rename lib/chessh/ssh/client/menus/{game_selector.ex => selector.ex} (86%) create mode 100644 priv/repo/migrations/20230529193453_add_citext.exs create mode 100644 priv/repo/migrations/20230529193504_add_bots.exs diff --git a/README.md b/README.md index a22b7ef..636ac77 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ looking for opponents, or in games when it is one's turn. https://user-images.githubusercontent.com/25559600/221317658-a80046ca-6009-456d-b43c-67d95baa4bf6.mp4 +## Bots +As a user, you can create 2 bot accounts at [/bots](https://chessh.linux.usu.edu/bots). These are driven by +webhooks. + +For an example bot, see [chessh example bot](https://github.com/Simponic/chessh_bot). + ## Usage ### Dependencies diff --git a/config/config.exs b/config/config.exs index 11c0a93..33f88cb 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: 30_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: , }, + { + path: "bots", + element: , + }, { path: "man-pages", element: , diff --git a/front/src/root.jsx b/front/src/root.jsx index 2b1e603..82e79c3 100644 --- a/front/src/root.jsx +++ b/front/src/root.jsx @@ -10,18 +10,18 @@ export const Root = () => { return ( <>
+
+ + CheSSH Logo + +
-
- - CheSSH Logo - -
- - Man Pages - {signedIn ? ( <> + + Sign Out + Home @@ -31,8 +31,8 @@ export const Root = () => { Keys - - Sign Out + + Bots ) : ( @@ -45,6 +45,10 @@ export const Root = () => { )} + + + Man Pages +
diff --git a/front/src/routes/bots.jsx b/front/src/routes/bots.jsx new file mode 100644 index 0000000..c2f1565 --- /dev/null +++ b/front/src/routes/bots.jsx @@ -0,0 +1,194 @@ +import Modal from "react-modal"; +import { useAuthContext } from "../context/auth_context"; +import { useEffect, useState } from "react"; + +Modal.setAppElement("#root"); + +const BotButton = ({ onSave, givenBot }) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(givenBot?.name || ""); + const [webhook, setWebhook] = useState(givenBot?.webhook || ""); + const [errors, setErrors] = useState(null); + const [isPublic, setIsPublic] = useState(givenBot?.public || false); + + const setDefaults = () => { + setName(""); + setWebhook(""); + setErrors(null); + }; + + const close = () => { + if (!givenBot) { + setDefaults(); + } + setOpen(false); + }; + + const updateBot = () => { + fetch(givenBot ? `/api/player/bots/${givenBot.id}` : "/api/player/bots", { + credentials: "same-origin", + method: givenBot ? "PUT" : "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + webhook: webhook.trim(), + name: name.trim(), + public: isPublic, + }), + }) + .then((r) => r.json()) + .then((d) => { + if (d.success) { + if (onSave) { + onSave(); + } + close(); + } else if (d.errors) { + if (typeof d.errors === "object") { + setErrors( + Object.keys(d.errors).map( + (field) => `${field}: ${d.errors[field].join(",")}` + ) + ); + } else { + setErrors([d.errors]); + } + } + }); + }; + + return ( +
+ + {givenBot && ( + <> + + + + )} + +
+

Add Bot

+
+

Bot Name *

+ setName(e.target.value)} + required + /> +
+
+

Webhook *

+ setWebhook(e.target.value)} + required + /> +
+

+ Public *{" "} + setIsPublic(!isPublic)} + required + /> +

+
+ {errors && ( +
+ {errors.map((error, i) => ( +

{error}

+ ))} +
+ )} +
+
+ + +
+
+
+ ); +}; + +export const BotCard = ({ botStruct, onSave }) => { + const { name } = botStruct; + return ( +
+

{name}

+ +
+ ); +}; + +export const Bots = () => { + const { + player: { id: userId }, + } = useAuthContext(); + const [bots, setBots] = useState(null); + + const refreshBots = () => + fetch("/api/player/bots") + .then((r) => r.json()) + .then((bots) => setBots(bots)); + + useEffect(() => { + if (userId) { + refreshBots(); + } + }, [userId]); + + if (bots === null) return

Loading...

; + + return ( + <> +

Bots

+ + +
+ {bots.length ? ( + bots.map((bot) => ( + + )) + ) : ( +

Looks like you've got no bots, try adding one!

+ )} +
+ + ); +}; diff --git a/front/src/routes/keys.jsx b/front/src/routes/keys.jsx index 5b50fa9..b9d4542 100644 --- a/front/src/routes/keys.jsx +++ b/front/src/routes/keys.jsx @@ -14,16 +14,14 @@ const minimizeKey = (key) => { return key; }; -const KeyCard = ({ onDelete, props }) => { - const { id, name, key } = props; - +const KeyCard = ({ onDelete, keyStruct: { id, name, key } }) => { const deleteThisKey = () => { if ( window.confirm( "Are you sure? This will close all your currently opened ssh sessions." ) ) { - fetch(`/api/keys/${id}`, { + fetch(`/api/player/keys/${id}`, { credentials: "same-origin", method: "DELETE", }) @@ -182,7 +180,7 @@ export const Keys = () => { } }, [userId, refreshKeys]); - if (!keys) return

Loading...

; + if (keys === null) return

Loading...

; if (Array.isArray(keys)) { return ( @@ -192,7 +190,7 @@ export const Keys = () => {
{keys.length ? ( keys.map((key) => ( - + )) ) : (

Looks like you've got no keys, try adding some!

diff --git a/front/src/routes/man_pages.jsx b/front/src/routes/man_pages.jsx index ea03c2a..f904394 100644 --- a/front/src/routes/man_pages.jsx +++ b/front/src/routes/man_pages.jsx @@ -74,7 +74,11 @@ export const ManPages = () => {
  • In the "Previous Games" viewer, use "m" to show the game's move history in UCI notation (which you may convert to PGN{" "} - + here ). diff --git a/lib/chessh/schema/bot.ex b/lib/chessh/schema/bot.ex new file mode 100644 index 0000000..9a86d69 --- /dev/null +++ b/lib/chessh/schema/bot.ex @@ -0,0 +1,77 @@ +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, + 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), + 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..738832e 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} @@ -365,7 +353,7 @@ defmodule Chessh.SSH.Client.Game do from, to, %State{ - game: %Game{game_moves: game_moves, id: game_id, turn: turn}, + game: %Game{id: game_id, turn: turn}, binbo_pid: binbo_pid, flipped: flipped, color: turn @@ -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..ecd1803 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 @@ -9,10 +10,11 @@ defmodule Chessh.SSH.Client.Game.Renderer do @tile_width 7 @tile_height 4 - @previous_move_background ANSI.light_magenta_background() - @from_select_background ANSI.light_green_background() - @to_select_background ANSI.light_yellow_background() - @in_check_color ANSI.yellow_background() + @previous_move_background ANSI.color_background(208) + @from_select_background ANSI.color_background(105) + + @to_select_background ANSI.color_background(177) + @in_check_color ANSI.color_background(197) @dark_piece_color ANSI.red() @light_piece_color ANSI.light_cyan() @@ -42,29 +44,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 +63,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 +91,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 +126,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 +356,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..86fc7cd --- /dev/null +++ b/lib/chessh/ssh/client/menus/select_bot.ex @@ -0,0 +1,119 @@ +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() + + spawn(fn -> Bot.send_update(game |> Repo.preload([:bot])) end) + + {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..ec551c8 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{} = 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