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