squash all the things for bots

This commit is contained in:
Elizabeth Hunt 2023-05-29 12:50:27 -07:00
parent 8a5a2f358c
commit cdd02424f4
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
24 changed files with 888 additions and 186 deletions

View File

@ -7,6 +7,7 @@ config :chessh,
ascii_chars_json_file: Path.join(Path.dirname(__DIR__), "priv/ascii_chars.json") ascii_chars_json_file: Path.join(Path.dirname(__DIR__), "priv/ascii_chars.json")
config :chessh, RateLimits, config :chessh, RateLimits,
player_bots: 2,
jail_timeout_ms: 5 * 60 * 1000, jail_timeout_ms: 5 * 60 * 1000,
jail_attempt_threshold: 15, jail_attempt_threshold: 15,
max_concurrent_user_sessions: 5, max_concurrent_user_sessions: 5,
@ -16,7 +17,9 @@ config :chessh, RateLimits,
create_game_ms: 60 * 1000, create_game_ms: 60 * 1000,
create_game_rate: 3, create_game_rate: 3,
discord_notification_rate: 30, 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, config :chessh, Web,
discord_oauth_login_url: "https://discord.com/api/oauth2/token", discord_oauth_login_url: "https://discord.com/api/oauth2/token",

View File

@ -69,7 +69,7 @@ body {
.navbar { .navbar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: center;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -7,6 +7,7 @@ import { Root } from "./root";
import { Demo } from "./routes/demo"; import { Demo } from "./routes/demo";
import { Home } from "./routes/home"; import { Home } from "./routes/home";
import { Keys } from "./routes/keys"; import { Keys } from "./routes/keys";
import { Bots } from "./routes/bots";
import { ManPages } from "./routes/man_pages"; import { ManPages } from "./routes/man_pages";
import { Password } from "./routes/password"; import { Password } from "./routes/password";
import { AuthSuccessful } from "./routes/auth_successful"; import { AuthSuccessful } from "./routes/auth_successful";
@ -36,6 +37,10 @@ const router = createBrowserRouter([
path: "auth-successful", path: "auth-successful",
element: <AuthSuccessful />, element: <AuthSuccessful />,
}, },
{
path: "bots",
element: <Bots />,
},
{ {
path: "man-pages", path: "man-pages",
element: <ManPages />, element: <ManPages />,

View File

@ -10,18 +10,18 @@ export const Root = () => {
return ( return (
<> <>
<div className="container"> <div className="container">
<div className="navbar">
<div className="flex-row-around"> <div className="flex-row-around">
<Link to="/home"> <Link to="/home">
<img src={logo} className="logo" alt="CheSSH Logo" /> <img src={logo} className="logo" alt="CheSSH Logo" />
</Link> </Link>
</div> </div>
<div className="navbar">
<div className="nav"> <div className="nav">
<Link className="link" to="/man-pages">
Man Pages
</Link>
{signedIn ? ( {signedIn ? (
<> <>
<Link className="button" onClick={signOut} to="/">
Sign Out
</Link>
<Link className="link" to="/home"> <Link className="link" to="/home">
Home Home
</Link> </Link>
@ -31,8 +31,8 @@ export const Root = () => {
<Link className="link" to="/keys"> <Link className="link" to="/keys">
Keys Keys
</Link> </Link>
<Link className="button" onClick={signOut} to="/"> <Link className="link" to="/bots">
Sign Out Bots
</Link> </Link>
</> </>
) : ( ) : (
@ -45,6 +45,10 @@ export const Root = () => {
</a> </a>
</> </>
)} )}
<Link className="link" to="/man-pages">
Man Pages
</Link>
</div> </div>
</div> </div>
<div className="content"> <div className="content">

194
front/src/routes/bots.jsx Normal file
View File

@ -0,0 +1,194 @@
import Modal from "react-modal";
import { useAuthContext } from "../context/auth_context";
import { useEffect, useState, useCallback } 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 (
<div>
<button className="button" onClick={() => setOpen(true)}>
{givenBot ? "Update" : "+ Add"} Bot
</button>
{givenBot && (
<>
<button
style={{ marginLeft: "1rem" }}
className="button gold"
onClick={() => {
navigator.clipboard.writeText(givenBot?.token);
alert("Bot's token was copied to the clipboard.");
}}
>
Copy Token
</button>
<button
style={{ marginLeft: "1rem" }}
className="button red"
onClick={() =>
fetch(`/api/player/bots/${givenBot.id}/redrive`)
.then((r) => r.json())
.then(({ message }) => alert(message))
}
>
Schedule Redrive
</button>
</>
)}
<Modal
isOpen={open}
onRequestClose={close}
className="modal"
contentLabel="Add Bot"
>
<div style={{ minWidth: "20vw" }}>
<h3>Add Bot</h3>
<hr />
<p>Bot Name *</p>
<input
style={{ width: "100%" }}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<p>Webhook *</p>
<input
style={{ width: "100%" }}
value={webhook}
onChange={(e) => setWebhook(e.target.value)}
required
/>
</div>
<p>
Public *{" "}
<input
type="checkbox"
value={name}
checked={isPublic}
onChange={(e) => setIsPublic(!isPublic)}
required
/>
</p>
<div>
{errors && (
<div style={{ color: "red" }}>
{errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
</div>
<div className="flex-end-row">
<button className="button" onClick={updateBot}>
{givenBot ? "Update" : "+ Add"}
</button>
<button className="button red" onClick={close}>
Cancel
</button>
</div>
</Modal>
</div>
);
};
export const BotCard = ({ botStruct, onSave }) => {
const { name, token } = botStruct;
return (
<div className="key-card">
<h4>{name}</h4>
<BotButton onSave={onSave} givenBot={botStruct} />
</div>
);
};
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 <p>Loading...</p>;
return (
<>
<h1>Bots</h1>
<BotButton onSave={refreshBots} />
<div className="key-card-collection">
{bots.length ? (
bots.map((bot) => (
<BotCard key={bot.id} onSave={refreshBots} botStruct={bot} />
))
) : (
<p>Looks like you've got no bots, try adding one!</p>
)}
</div>
</>
);
};

View File

@ -14,16 +14,14 @@ const minimizeKey = (key) => {
return key; return key;
}; };
const KeyCard = ({ onDelete, props }) => { const KeyCard = ({ onDelete, keyStruct: { id, name, key } }) => {
const { id, name, key } = props;
const deleteThisKey = () => { const deleteThisKey = () => {
if ( if (
window.confirm( window.confirm(
"Are you sure? This will close all your currently opened ssh sessions." "Are you sure? This will close all your currently opened ssh sessions."
) )
) { ) {
fetch(`/api/keys/${id}`, { fetch(`/api/player/keys/${id}`, {
credentials: "same-origin", credentials: "same-origin",
method: "DELETE", method: "DELETE",
}) })
@ -182,7 +180,7 @@ export const Keys = () => {
} }
}, [userId, refreshKeys]); }, [userId, refreshKeys]);
if (!keys) return <p>Loading...</p>; if (keys === null) return <p>Loading...</p>;
if (Array.isArray(keys)) { if (Array.isArray(keys)) {
return ( return (
@ -192,7 +190,7 @@ export const Keys = () => {
<div className="key-card-collection"> <div className="key-card-collection">
{keys.length ? ( {keys.length ? (
keys.map((key) => ( keys.map((key) => (
<KeyCard key={key.id} onDelete={refreshKeys} props={key} /> <KeyCard key={key.id} onDelete={refreshKeys} keyStruct={key} />
)) ))
) : ( ) : (
<p>Looks like you've got no keys, try adding some!</p> <p>Looks like you've got no keys, try adding some!</p>

79
lib/chessh/schema/bot.ex Normal file
View File

@ -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

View File

@ -1,8 +1,10 @@
defmodule Chessh.Game do defmodule Chessh.Game do
alias Chessh.Player alias Chessh.{Bot, Player, Game}
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
@default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
schema "games" do schema "games" do
field(:fen, :string) field(:fen, :string)
field(:moves, :integer, default: 0) 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(:light_player, Player, foreign_key: :light_player_id)
belongs_to(:dark_player, Player, foreign_key: :dark_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) field(:discord_thread_id, :string)
timestamps() timestamps()
@ -34,7 +38,51 @@ defmodule Chessh.Game do
:light_player_id, :light_player_id,
:dark_player_id, :dark_player_id,
:discord_thread_id, :discord_thread_id,
:bot_id,
:game_moves :game_moves
]) ])
end 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 end

View File

@ -1,7 +1,7 @@
defmodule Chessh.Player do defmodule Chessh.Player do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Chessh.{Key, Game} alias Chessh.{Key, Game, Bot}
@derive {Inspect, except: [:password]} @derive {Inspect, except: [:password]}
schema "players" do schema "players" do
@ -17,6 +17,7 @@ defmodule Chessh.Player do
has_many(:keys, Key) has_many(:keys, Key)
has_many(:light_games, Game, foreign_key: :light_player_id, references: :id) 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(:dark_games, Game, foreign_key: :dark_player_id, references: :id)
has_many(:bots, Bot, foreign_key: :player_id, references: :id)
timestamps() timestamps()
end end

View File

@ -156,8 +156,8 @@ defmodule Chessh.SSH.Client do
# C-b # C-b
<<2>> -> :menu <<2>> -> :menu
# Escape # Escape
"\e" -> :menu "\e" -> :escape
# VIM keys, per request # VIM keys
"k" -> :up "k" -> :up
"j" -> :down "j" -> :down
"h" -> :left "h" -> :left

View File

@ -1,10 +1,8 @@
defmodule Chessh.SSH.Client.Game do defmodule Chessh.SSH.Client.Game do
require Logger require Logger
alias Chessh.{Game, Utils, Repo} alias Chessh.{Game, Utils, Repo, Bot}
alias Chessh.SSH.Client.Game.Renderer alias Chessh.SSH.Client.Game.Renderer
@default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
defmodule State do defmodule State do
defstruct cursor: %{x: 7, y: 7}, defstruct cursor: %{x: 7, y: 7},
highlighted: %{}, highlighted: %{},
@ -69,27 +67,14 @@ defmodule Chessh.SSH.Client.Game do
case Hammer.check_rate_inc( case Hammer.check_rate_inc(
:redis, :redis,
"player-#{state.player_session.id}-create-game-rate", "player-#{state.player_session.player_id}-create-game-rate",
create_game_ms, create_game_ms,
create_game_rate, create_game_rate,
1 1
) do ) do
{:allow, _count} -> {:allow, _count} ->
# Starting a new game game = Game.new_game(color, player_session.player_id) |> Repo.insert!()
{:ok, %Game{id: game_id} = game} = %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()
GenServer.cast( GenServer.cast(
:discord_notifier, :discord_notifier,
@ -129,13 +114,15 @@ defmodule Chessh.SSH.Client.Game do
id: game_id, id: game_id,
fen: fen, fen: fen,
dark_player_id: dark_player_id, dark_player_id: dark_player_id,
light_player_id: light_player_id light_player_id: light_player_id,
bot_id: bot_id
} = game } = game
} = state } = state
| _ | _
]) do ]) do
maybe_changeset = maybe_changeset =
case color do if !bot_id do
case(color) do
:light -> :light ->
if !light_player_id, if !light_player_id,
do: Game.changeset(game, %{light_player_id: player_session.player_id}) do: Game.changeset(game, %{light_player_id: player_session.player_id})
@ -144,6 +131,7 @@ defmodule Chessh.SSH.Client.Game do
if !dark_player_id, if !dark_player_id,
do: Game.changeset(game, %{dark_player_id: player_session.player_id}) do: Game.changeset(game, %{dark_player_id: player_session.player_id})
end end
end
{status, maybe_joined_game} = {status, maybe_joined_game} =
if maybe_changeset do if maybe_changeset do
@ -164,7 +152,7 @@ defmodule Chessh.SSH.Client.Game do
end end
binbo_pid = initialize_game(game_id, fen) 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) 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{ end).(%State{
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)}) send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)})
@ -218,7 +206,7 @@ defmodule Chessh.SSH.Client.Game do
:player_joined, :player_joined,
%State{client_pid: client_pid, game: %Game{id: game_id}} = state %State{client_pid: client_pid, game: %Game{id: game_id}} = state
) do ) 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} new_state = %State{state | game: game}
send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)}) send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)})
{:noreply, new_state} {:noreply, new_state}
@ -391,22 +379,15 @@ defmodule Chessh.SSH.Client.Game do
{:ok, status} -> {:ok, status} ->
{:ok, fen} = :binbo.get_fen(binbo_pid) {:ok, fen} = :binbo.get_fen(binbo_pid)
{:ok, %Game{status: after_move_status}} = {:ok, %Game{status: after_move_status} = game} =
game game
|> Game.changeset( |> Game.update_with_status(attempted_move, fen, status)
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)
)
)
|> Repo.update() |> 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}) :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move})
if after_move_status == :continue do if after_move_status == :continue do
@ -433,22 +414,6 @@ defmodule Chessh.SSH.Client.Game do
nil nil
end 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( defp make_highlight_map(
%State{ %State{
game: %Game{last_move: last_move, turn: turn}, game: %Game{last_move: last_move, turn: turn},

View File

@ -2,6 +2,7 @@ defmodule Chessh.SSH.Client.Game.Renderer do
alias IO.ANSI alias IO.ANSI
alias Chessh.{Utils, Player} alias Chessh.{Utils, Player}
alias Chessh.SSH.Client.Game alias Chessh.SSH.Client.Game
require Logger
@chess_board_height 8 @chess_board_height 8
@chess_board_width 8 @chess_board_width 8
@ -42,29 +43,17 @@ defmodule Chessh.SSH.Client.Game.Renderer do
%Game.State{ %Game.State{
game: game:
%Chessh.Game{ %Chessh.Game{
light_player: light_player 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{
dark_player: dark_player dark_player: dark_player
} = game } = game
} = state } = 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{ render_board_state(%Game.State{
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 end
@ -73,9 +62,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do
flipped: flipped, flipped: flipped,
game: game:
%Chessh.Game{ %Chessh.Game{
fen: fen fen: fen,
light_player: light_player,
dark_player: dark_player
} = game } = game
}) do })
when not is_nil(light_player) and not is_nil(dark_player) do
rendered = [ rendered = [
ANSI.clear_line(), ANSI.clear_line(),
make_status_line(game, true) make_status_line(game, true)
@ -98,29 +90,19 @@ defmodule Chessh.SSH.Client.Game.Renderer do
def make_status_line( def make_status_line(
%Chessh.Game{ %Chessh.Game{
light_player: light_player 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{
dark_player: dark_player dark_player: dark_player
} = game, } = game,
fancy fancy
) )
when is_nil(dark_player), when is_nil(light_player) or is_nil(dark_player) do
do: {light_player, dark_player} = get_players(game)
make_status_line( make_status_line(
%Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}}, %Chessh.Game{game | light_player: light_player, dark_player: dark_player},
fancy fancy
) )
end
def make_status_line( def make_status_line(
%Chessh.Game{ %Chessh.Game{
@ -143,12 +125,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do
"Game #{game_id} - ", "Game #{game_id} - ",
if(fancy, if(fancy,
do: ANSI.format_fragment([@light_piece_color, light_player]), 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.default_color(), else: ""} --vs-- ",
if(fancy, if(fancy,
do: ANSI.format_fragment([@dark_piece_color, dark_player]), do: ANSI.format_fragment([@dark_piece_color, dark_player]),
else: "#{dark_player} (D)" else: "#{dark_player}"
), ),
if(fancy, do: ANSI.default_color(), else: ""), if(fancy, do: ANSI.default_color(), else: ""),
case status do case status do
@ -373,4 +355,25 @@ defmodule Chessh.SSH.Client.Game.Renderer do
Map.merge(acc, pieces_map_for_this_row) Map.merge(acc, pieces_map_for_this_row)
end) end)
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 end

View File

@ -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

View File

@ -1,6 +1,5 @@
defmodule Chessh.SSH.Client.MainMenu do defmodule Chessh.SSH.Client.MainMenu do
alias IO.ANSI alias IO.ANSI
alias Chessh.PlayerSession
require Logger require Logger
@ -22,9 +21,12 @@ defmodule Chessh.SSH.Client.MainMenu do
def max_box_cols(), do: @logo_cols def max_box_cols(), do: @logo_cols
def title(), do: @logo ++ ["- Connected on: #{System.get_env("NODE_ID")}"] 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.SelectCurrentGame,
%Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}},
{"Joinable Games (lobby)", {"Joinable Games (lobby)",
@ -32,13 +34,7 @@ defmodule Chessh.SSH.Client.MainMenu do
%Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}},
{"Previous Games", {"Previous Games",
{Chessh.SSH.Client.SelectPreviousGame, {Chessh.SSH.Client.SelectPreviousGame,
%Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}, %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}}}
] ]
end end

View File

@ -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

View File

@ -1,6 +1,6 @@
defmodule Chessh.SSH.Client.SelectCurrentGame do defmodule Chessh.SSH.Client.SelectCurrentGame do
alias Chessh.{Utils, Repo, Game, PlayerSession} alias Chessh.{Utils, Repo, Game, PlayerSession}
alias Chessh.SSH.Client.GameSelector alias Chessh.SSH.Client.Selector
import Ecto.Query import Ecto.Query
require Logger require Logger
@ -12,7 +12,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do
def dynamic_options(), do: true def dynamic_options(), do: true
def get_player_sorted_current_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do 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 Game
|> where([g], g.status == :continue) |> where([g], g.status == :continue)
|> where([g], g.light_player_id == ^player_id or g.dark_player_id == ^player_id) |> 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, current_id,
direction direction
) )
|> Repo.preload([:light_player, :dark_player, :bot])
end end
def format_game_selection_tuple(%Game{id: game_id} = game) do 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] order_by: [desc: g.id]
) )
|> Repo.all() |> Repo.all()
|> Repo.preload([:light_player, :dark_player]) |> Repo.preload([:light_player, :dark_player, :bot])
|> Enum.map(&format_game_selection_tuple/1) |> Enum.map(&format_game_selection_tuple/1)
end end
@ -78,7 +79,7 @@ defmodule Chessh.SSH.Client.SelectCurrentGame do
previous_last_game_id = previous_last_game_id =
case List.last(options) do case List.last(options) do
{_label, id} -> id {_label, id} -> id
_ -> 0 _ -> 1
end end
current_screen_games = current_screen_games =

View File

@ -1,7 +1,8 @@
defmodule Chessh.SSH.Client.SelectJoinableGame do defmodule Chessh.SSH.Client.SelectJoinableGame do
alias Chessh.{Utils, Repo, Game, PlayerSession} alias Chessh.{Utils, Repo, Game, PlayerSession}
alias Chessh.SSH.Client.GameSelector alias Chessh.SSH.Client.Selector
import Ecto.Query import Ecto.Query
require Logger
use Chessh.SSH.Client.SelectPaginatePoller use Chessh.SSH.Client.SelectPaginatePoller
@ -12,18 +13,20 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do
def dynamic_options(), do: true def dynamic_options(), do: true
def get_player_joinable_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do def get_player_joinable_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do
GameSelector.paginate_ish_query( Selector.paginate_ish_query(
Game Game
|> where([g], g.status == :continue) |> where([g], g.status == :continue)
|> where( |> where(
[g], [g],
(is_nil(g.dark_player_id) or is_nil(g.light_player_id)) and (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()), |> limit(^max_displayed_options()),
current_id, current_id,
direction direction
) )
|> Repo.preload([:light_player, :dark_player, :bot])
end end
def format_game_selection_tuple(%Game{id: game_id} = game) do def format_game_selection_tuple(%Game{id: game_id} = game) do
@ -60,6 +63,8 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do
end end
def initial_options(%State{player_session: %PlayerSession{player_id: player_id}}) do def initial_options(%State{player_session: %PlayerSession{player_id: player_id}}) do
Logger.info(player_id)
get_player_joinable_games_with_id(player_id) get_player_joinable_games_with_id(player_id)
|> Enum.map(&format_game_selection_tuple/1) |> Enum.map(&format_game_selection_tuple/1)
end end
@ -71,7 +76,7 @@ defmodule Chessh.SSH.Client.SelectJoinableGame do
previous_last_game_id = previous_last_game_id =
case List.last(options) do case List.last(options) do
{_label, id} -> id {_label, id} -> id
_ -> 0 _ -> 1
end end
current_screen_games = current_screen_games =

View File

@ -26,7 +26,8 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
player_session: nil, player_session: nil,
options: [], options: [],
tick: 0, tick: 0,
cursor: nil cursor: nil,
extra_info: %{}
end end
defmacro __using__(_) do defmacro __using__(_) do
@ -127,8 +128,14 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
max_item = min(length(options), max_displayed_options()) max_item = min(length(options), max_displayed_options())
new_state = new_state =
if(max_item > 0, if max_item > 0 do
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 case action do
:up -> :up ->
%State{ %State{
@ -162,16 +169,14 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
tick: 0 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
_ -> _ ->
nil state
end
else
state
end
end
end end
) || state
if !(action == :return) do if !(action == :return) do
render(width, height, new_state) render(width, height, new_state)
@ -229,7 +234,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
if i == selected_option_idx do if i == selected_option_idx do
ANSI.format_fragment( ANSI.format_fragment(
[:light_cyan, :bright, "> #{line} <", :reset], [:light_cyan, :bright, "#{line}", :reset],
true true
) )
else else
@ -238,7 +243,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
end end
) )
else 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
end end

View File

@ -1,6 +1,6 @@
defmodule Chessh.SSH.Client.SelectPreviousGame do defmodule Chessh.SSH.Client.SelectPreviousGame do
alias Chessh.{Utils, Repo, Game, PlayerSession} alias Chessh.{Utils, Repo, Game, PlayerSession}
alias Chessh.SSH.Client.GameSelector alias Chessh.SSH.Client.Selector
import Ecto.Query import Ecto.Query
require Logger require Logger
@ -12,7 +12,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do
def dynamic_options(), do: true def dynamic_options(), do: true
def get_player_sorted_current_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do 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 Game
|> where([g], g.status != :continue) |> where([g], g.status != :continue)
|> where([g], g.light_player_id == ^player_id or g.dark_player_id == ^player_id) |> 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, current_id,
direction direction
) )
|> Repo.preload([:light_player, :dark_player, :bot])
end end
def format_game_selection_tuple(%Game{id: game_id} = game) do 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] order_by: [desc: g.id]
) )
|> Repo.all() |> Repo.all()
|> Repo.preload([:light_player, :dark_player]) |> Repo.preload([:light_player, :dark_player, :bot])
|> Enum.map(&format_game_selection_tuple/1) |> Enum.map(&format_game_selection_tuple/1)
end end
@ -78,7 +79,7 @@ defmodule Chessh.SSH.Client.SelectPreviousGame do
previous_last_game_id = previous_last_game_id =
case List.last(options) do case List.last(options) do
{_label, id} -> id {_label, id} -> id
_ -> 0 _ -> 1
end end
current_screen_games = current_screen_games =

View File

@ -1,4 +1,4 @@
defmodule Chessh.SSH.Client.GameSelector do defmodule Chessh.SSH.Client.Selector do
import Ecto.Query import Ecto.Query
alias Chessh.Repo alias Chessh.Repo
@ -17,7 +17,6 @@ defmodule Chessh.SSH.Client.GameSelector do
sorted_query sorted_query
end end
|> Repo.all() |> Repo.all()
|> Repo.preload([:light_player, :dark_player])
if direction == :desc, do: results, else: Enum.reverse(results) if direction == :desc, do: results, else: Enum.reverse(results)
end end

View File

@ -1,4 +1,6 @@
defmodule Chessh.Utils do defmodule Chessh.Utils do
require Logger
@ascii_chars Application.compile_env!(:chessh, :ascii_chars_json_file) @ascii_chars Application.compile_env!(:chessh, :ascii_chars_json_file)
|> File.read!() |> File.read!()
|> Jason.decode!() |> Jason.decode!()
@ -35,4 +37,8 @@ defmodule Chessh.Utils do
calc = index + delta calc = index + delta
if(calc < 0, do: length, else: 0) + rem(calc, length) if(calc < 0, do: length, else: 0) + rem(calc, length)
end end
def random_token() do
:crypto.strong_rand_bytes(16) |> Base.encode16()
end
end end

View File

@ -1,5 +1,5 @@
defmodule Chessh.Web.Endpoint do defmodule Chessh.Web.Endpoint do
alias Chessh.{Player, Repo, Key, PlayerSession} alias Chessh.{Player, Repo, Key, PlayerSession, Bot, Utils, Game}
alias Chessh.Web.Token alias Chessh.Web.Token
use Plug.Router use Plug.Router
import Ecto.Query import Ecto.Query
@ -108,7 +108,7 @@ defmodule Chessh.Web.Endpoint do
{status, body} = {status, body} =
case conn.body_params do case conn.body_params do
%{"key" => key, "name" => name} -> %{"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."}} {400, %{errors: "Player has reached threshold of #{max_key_count} keys."}}
else else
case Key.changeset(%Key{player_id: player.id}, %{key: key, name: name}) 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)) |> send_resp(200, Jason.encode!(keys))
end end
delete "/keys/:id" do delete "/player/keys/:id" do
player = get_player_from_jwt(conn) player = get_player_from_jwt(conn)
PlayerSession.close_all_player_sessions(player) PlayerSession.close_all_player_sessions(player)
@ -193,6 +193,203 @@ defmodule Chessh.Web.Endpoint do
|> send_resp(status, Jason.encode!(body)) |> send_resp(status, Jason.encode!(body))
end 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 match _ do
send_resp(conn, 404, "Route undefined") send_resp(conn, 404, "Route undefined")
end end

View File

@ -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

View File

@ -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