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 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 ## Usage
### Dependencies ### Dependencies

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: 30_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="flex-row-around">
<Link to="/home">
<img src={logo} className="logo" alt="CheSSH Logo" />
</Link>
</div>
<div className="navbar"> <div className="navbar">
<div className="flex-row-around">
<Link to="/home">
<img src={logo} className="logo" alt="CheSSH Logo" />
</Link>
</div>
<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 } 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; 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>

View File

@ -74,7 +74,11 @@ export const ManPages = () => {
<li> <li>
In the "Previous Games" viewer, use "m" to show the game's move In the "Previous Games" viewer, use "m" to show the game's move
history in UCI notation (which you may convert to PGN{" "} 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 here
</a> </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 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,20 +114,23 @@ 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
:light -> case(color) do
if !light_player_id, :light ->
do: Game.changeset(game, %{light_player_id: player_session.player_id}) if !light_player_id,
do: Game.changeset(game, %{light_player_id: player_session.player_id})
:dark -> :dark ->
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} =
@ -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}
@ -365,7 +353,7 @@ defmodule Chessh.SSH.Client.Game do
from, from,
to, to,
%State{ %State{
game: %Game{game_moves: game_moves, id: game_id, turn: turn}, game: %Game{id: game_id, turn: turn},
binbo_pid: binbo_pid, binbo_pid: binbo_pid,
flipped: flipped, flipped: flipped,
color: turn color: turn
@ -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
@ -9,10 +10,11 @@ defmodule Chessh.SSH.Client.Game.Renderer do
@tile_width 7 @tile_width 7
@tile_height 4 @tile_height 4
@previous_move_background ANSI.light_magenta_background() @previous_move_background ANSI.color_background(208)
@from_select_background ANSI.light_green_background() @from_select_background ANSI.color_background(105)
@to_select_background ANSI.light_yellow_background()
@in_check_color ANSI.yellow_background() @to_select_background ANSI.color_background(177)
@in_check_color ANSI.color_background(197)
@dark_piece_color ANSI.red() @dark_piece_color ANSI.red()
@light_piece_color ANSI.light_cyan() @light_piece_color ANSI.light_cyan()
@ -42,29 +44,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 +63,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 +91,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(
%Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}}, make_status_line(
fancy %Chessh.Game{game | light_player: light_player, dark_player: dark_player},
) fancy
)
end
def make_status_line( def make_status_line(
%Chessh.Game{ %Chessh.Game{
@ -143,12 +126,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 +356,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,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 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,51 +128,55 @@ 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
case action do {_, selected} = Enum.at(options, selected_option_idx)
:up -> {module, state} = make_process_tuple(selected, state)
%State{ send(client_pid, {:set_screen_process, module, state})
state state
| selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item), else
tick: 0 if(max_item > 1) do
} case action do
:up ->
:down -> %State{
%State{
state
| selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item),
tick: 0
}
:left ->
if dynamic_options(),
do: %State{
state state
| options: previous_page_options(state), | selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item),
selected_option_idx: 0,
tick: 0 tick: 0
} }
:right -> :down ->
if dynamic_options(), %State{
do: %State{
state state
| options: next_page_options(state), | selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item),
selected_option_idx: 0,
tick: 0 tick: 0
} }
:return -> :left ->
{_, selected} = Enum.at(options, selected_option_idx) if dynamic_options(),
{module, state} = make_process_tuple(selected, state) do: %State{
send(client_pid, {:set_screen_process, module, state}) state
state | options: previous_page_options(state),
selected_option_idx: 0,
tick: 0
}
_ -> :right ->
nil if dynamic_options(),
do: %State{
state
| options: next_page_options(state),
selected_option_idx: 0,
tick: 0
}
_ ->
state
end
else
state
end end
) || state end
end
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{} = 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