Bots #23

Merged
Simponic merged 4 commits from bots into main 2023-05-29 19:28:27 -04:00
26 changed files with 905 additions and 192 deletions

View File

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

View File

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

View File

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

View File

@ -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: <AuthSuccessful />,
},
{
path: "bots",
element: <Bots />,
},
{
path: "man-pages",
element: <ManPages />,

View File

@ -10,18 +10,18 @@ export const Root = () => {
return (
<>
<div className="container">
<div className="navbar">
<div className="flex-row-around">
<Link to="/home">
<img src={logo} className="logo" alt="CheSSH Logo" />
</Link>
</div>
<div className="navbar">
<div className="nav">
<Link className="link" to="/man-pages">
Man Pages
</Link>
{signedIn ? (
<>
<Link className="button" onClick={signOut} to="/">
Sign Out
</Link>
<Link className="link" to="/home">
Home
</Link>
@ -31,8 +31,8 @@ export const Root = () => {
<Link className="link" to="/keys">
Keys
</Link>
<Link className="button" onClick={signOut} to="/">
Sign Out
<Link className="link" to="/bots">
Bots
</Link>
</>
) : (
@ -45,6 +45,10 @@ export const Root = () => {
</a>
</>
)}
<Link className="link" to="/man-pages">
Man Pages
</Link>
</div>
</div>
<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 } 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 } = 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;
};
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 <p>Loading...</p>;
if (keys === null) return <p>Loading...</p>;
if (Array.isArray(keys)) {
return (
@ -192,7 +190,7 @@ export const Keys = () => {
<div className="key-card-collection">
{keys.length ? (
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>

View File

@ -74,7 +74,11 @@ export const ManPages = () => {
<li>
In the "Previous Games" viewer, use "m" to show the game's move
history in UCI notation (which you may convert to PGN{" "}
<a href="https://www.dcode.fr/uci-chess-notation" target="_blank">
<a
href="https://www.dcode.fr/uci-chess-notation"
target="_blank"
rel="noreferrer"
>
here
</a>
).

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

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

View File

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

View File

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

View File

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

View File

@ -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,13 +114,15 @@ 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
if !bot_id do
case(color) do
:light ->
if !light_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,
do: Game.changeset(game, %{dark_player_id: player_session.player_id})
end
end
{status, maybe_joined_game} =
if maybe_changeset do
@ -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},

View File

@ -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:
when is_nil(light_player) or is_nil(dark_player) do
{light_player, dark_player} = get_players(game)
make_status_line(
%Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}},
%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

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

View File

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

View File

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

View File

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

View File

@ -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,8 +128,14 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
max_item = min(length(options), max_displayed_options())
new_state =
if(max_item > 0,
do:
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{
@ -162,16 +169,14 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
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
) || state
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

View File

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

View File

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

View File

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

View File

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

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