Bots #23
@ -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
|
||||
|
@ -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",
|
||||
|
@ -69,7 +69,7 @@ body {
|
||||
.navbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin-bottom: 1rem;
|
||||
|
@ -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 />,
|
||||
|
@ -10,18 +10,18 @@ export const Root = () => {
|
||||
return (
|
||||
<>
|
||||
<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="flex-row-around">
|
||||
<Link to="/home">
|
||||
<img src={logo} className="logo" alt="CheSSH Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
<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
194
front/src/routes/bots.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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
77
lib/chessh/schema/bot.ex
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -1,10 +1,8 @@
|
||||
defmodule Chessh.SSH.Client.Game do
|
||||
require Logger
|
||||
alias Chessh.{Game, Utils, Repo}
|
||||
alias Chessh.{Game, Utils, Repo, Bot}
|
||||
alias Chessh.SSH.Client.Game.Renderer
|
||||
|
||||
@default_fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
defmodule State do
|
||||
defstruct cursor: %{x: 7, y: 7},
|
||||
highlighted: %{},
|
||||
@ -69,27 +67,14 @@ defmodule Chessh.SSH.Client.Game do
|
||||
|
||||
case Hammer.check_rate_inc(
|
||||
:redis,
|
||||
"player-#{state.player_session.id}-create-game-rate",
|
||||
"player-#{state.player_session.player_id}-create-game-rate",
|
||||
create_game_ms,
|
||||
create_game_rate,
|
||||
1
|
||||
) do
|
||||
{:allow, _count} ->
|
||||
# Starting a new game
|
||||
{:ok, %Game{id: game_id} = game} =
|
||||
Game.changeset(
|
||||
%Game{},
|
||||
Map.merge(
|
||||
if(color == :light,
|
||||
do: %{light_player_id: player_session.player_id},
|
||||
else: %{dark_player_id: player_session.player_id}
|
||||
),
|
||||
%{
|
||||
fen: @default_fen
|
||||
}
|
||||
)
|
||||
)
|
||||
|> Repo.insert()
|
||||
game = Game.new_game(color, player_session.player_id) |> Repo.insert!()
|
||||
%Game{id: game_id} = game
|
||||
|
||||
GenServer.cast(
|
||||
:discord_notifier,
|
||||
@ -129,20 +114,23 @@ defmodule Chessh.SSH.Client.Game do
|
||||
id: game_id,
|
||||
fen: fen,
|
||||
dark_player_id: dark_player_id,
|
||||
light_player_id: light_player_id
|
||||
light_player_id: light_player_id,
|
||||
bot_id: bot_id
|
||||
} = game
|
||||
} = state
|
||||
| _
|
||||
]) do
|
||||
maybe_changeset =
|
||||
case color do
|
||||
:light ->
|
||||
if !light_player_id,
|
||||
do: Game.changeset(game, %{light_player_id: player_session.player_id})
|
||||
if !bot_id do
|
||||
case(color) do
|
||||
:light ->
|
||||
if !light_player_id,
|
||||
do: Game.changeset(game, %{light_player_id: player_session.player_id})
|
||||
|
||||
:dark ->
|
||||
if !dark_player_id,
|
||||
do: Game.changeset(game, %{dark_player_id: player_session.player_id})
|
||||
:dark ->
|
||||
if !dark_player_id,
|
||||
do: Game.changeset(game, %{dark_player_id: player_session.player_id})
|
||||
end
|
||||
end
|
||||
|
||||
{status, maybe_joined_game} =
|
||||
@ -164,7 +152,7 @@ defmodule Chessh.SSH.Client.Game do
|
||||
end
|
||||
|
||||
binbo_pid = initialize_game(game_id, fen)
|
||||
game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player])
|
||||
game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot])
|
||||
|
||||
player_color = if(game.light_player_id == player_session.player_id, do: :light, else: :dark)
|
||||
|
||||
@ -206,7 +194,7 @@ defmodule Chessh.SSH.Client.Game do
|
||||
}
|
||||
end).(%State{
|
||||
state
|
||||
| game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player])
|
||||
| game: Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot])
|
||||
})
|
||||
|
||||
send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)})
|
||||
@ -218,7 +206,7 @@ defmodule Chessh.SSH.Client.Game do
|
||||
:player_joined,
|
||||
%State{client_pid: client_pid, game: %Game{id: game_id}} = state
|
||||
) do
|
||||
game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player])
|
||||
game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player, :bot])
|
||||
new_state = %State{state | game: game}
|
||||
send(client_pid, {:send_to_ssh, Renderer.render_board_state(new_state)})
|
||||
{:noreply, new_state}
|
||||
@ -365,7 +353,7 @@ defmodule Chessh.SSH.Client.Game do
|
||||
from,
|
||||
to,
|
||||
%State{
|
||||
game: %Game{game_moves: game_moves, id: game_id, turn: turn},
|
||||
game: %Game{id: game_id, turn: turn},
|
||||
binbo_pid: binbo_pid,
|
||||
flipped: flipped,
|
||||
color: turn
|
||||
@ -391,22 +379,15 @@ defmodule Chessh.SSH.Client.Game do
|
||||
{:ok, status} ->
|
||||
{:ok, fen} = :binbo.get_fen(binbo_pid)
|
||||
|
||||
{:ok, %Game{status: after_move_status}} =
|
||||
{:ok, %Game{status: after_move_status} = game} =
|
||||
game
|
||||
|> Game.changeset(
|
||||
Map.merge(
|
||||
%{
|
||||
fen: fen,
|
||||
moves: game.moves + 1,
|
||||
turn: if(game.turn == :dark, do: :light, else: :dark),
|
||||
last_move: attempted_move,
|
||||
game_moves: if(game_moves, do: game_moves <> " ", else: "") <> attempted_move
|
||||
},
|
||||
changeset_from_status(status)
|
||||
)
|
||||
)
|
||||
|> Game.update_with_status(attempted_move, fen, status)
|
||||
|> Repo.update()
|
||||
|
||||
if !is_nil(game.bot) do
|
||||
spawn(fn -> Bot.send_update(Repo.get(Game, game.id) |> Repo.preload([:bot])) end)
|
||||
end
|
||||
|
||||
:syn.publish(:games, {:game, game_id}, {:new_move, attempted_move})
|
||||
|
||||
if after_move_status == :continue do
|
||||
@ -433,22 +414,6 @@ defmodule Chessh.SSH.Client.Game do
|
||||
nil
|
||||
end
|
||||
|
||||
defp changeset_from_status(game_status) do
|
||||
case game_status do
|
||||
:continue ->
|
||||
%{}
|
||||
|
||||
{:draw, _} ->
|
||||
%{status: :draw}
|
||||
|
||||
{:checkmate, :white_wins} ->
|
||||
%{status: :winner, winner: :light}
|
||||
|
||||
{:checkmate, :black_wins} ->
|
||||
%{status: :winner, winner: :dark}
|
||||
end
|
||||
end
|
||||
|
||||
defp make_highlight_map(
|
||||
%State{
|
||||
game: %Game{last_move: last_move, turn: turn},
|
||||
|
@ -2,6 +2,7 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
alias IO.ANSI
|
||||
alias Chessh.{Utils, Player}
|
||||
alias Chessh.SSH.Client.Game
|
||||
require Logger
|
||||
|
||||
@chess_board_height 8
|
||||
@chess_board_width 8
|
||||
@ -9,10 +10,11 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
@tile_width 7
|
||||
@tile_height 4
|
||||
|
||||
@previous_move_background ANSI.light_magenta_background()
|
||||
@from_select_background ANSI.light_green_background()
|
||||
@to_select_background ANSI.light_yellow_background()
|
||||
@in_check_color ANSI.yellow_background()
|
||||
@previous_move_background ANSI.color_background(208)
|
||||
@from_select_background ANSI.color_background(105)
|
||||
|
||||
@to_select_background ANSI.color_background(177)
|
||||
@in_check_color ANSI.color_background(197)
|
||||
|
||||
@dark_piece_color ANSI.red()
|
||||
@light_piece_color ANSI.light_cyan()
|
||||
@ -42,29 +44,17 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
%Game.State{
|
||||
game:
|
||||
%Chessh.Game{
|
||||
light_player: light_player
|
||||
} = game
|
||||
} = state
|
||||
)
|
||||
when is_nil(light_player) do
|
||||
render_board_state(%Game.State{
|
||||
state
|
||||
| game: %Chessh.Game{game | light_player: %Player{username: "(no opponent)"}}
|
||||
})
|
||||
end
|
||||
|
||||
def render_board_state(
|
||||
%Game.State{
|
||||
game:
|
||||
%Chessh.Game{
|
||||
light_player: light_player,
|
||||
dark_player: dark_player
|
||||
} = game
|
||||
} = state
|
||||
)
|
||||
when is_nil(dark_player) do
|
||||
when is_nil(light_player) or is_nil(dark_player) do
|
||||
{light_player, dark_player} = get_players(game)
|
||||
|
||||
render_board_state(%Game.State{
|
||||
state
|
||||
| game: %Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}}
|
||||
| game: %Chessh.Game{game | light_player: light_player, dark_player: dark_player}
|
||||
})
|
||||
end
|
||||
|
||||
@ -73,9 +63,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
flipped: flipped,
|
||||
game:
|
||||
%Chessh.Game{
|
||||
fen: fen
|
||||
fen: fen,
|
||||
light_player: light_player,
|
||||
dark_player: dark_player
|
||||
} = game
|
||||
}) do
|
||||
})
|
||||
when not is_nil(light_player) and not is_nil(dark_player) do
|
||||
rendered = [
|
||||
ANSI.clear_line(),
|
||||
make_status_line(game, true)
|
||||
@ -98,29 +91,19 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
|
||||
def make_status_line(
|
||||
%Chessh.Game{
|
||||
light_player: light_player
|
||||
} = game,
|
||||
fancy
|
||||
)
|
||||
when is_nil(light_player),
|
||||
do:
|
||||
make_status_line(
|
||||
%Chessh.Game{game | light_player: %Player{username: "(no opponent)"}},
|
||||
fancy
|
||||
)
|
||||
|
||||
def make_status_line(
|
||||
%Chessh.Game{
|
||||
light_player: light_player,
|
||||
dark_player: dark_player
|
||||
} = game,
|
||||
fancy
|
||||
)
|
||||
when is_nil(dark_player),
|
||||
do:
|
||||
make_status_line(
|
||||
%Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}},
|
||||
fancy
|
||||
)
|
||||
when is_nil(light_player) or is_nil(dark_player) do
|
||||
{light_player, dark_player} = get_players(game)
|
||||
|
||||
make_status_line(
|
||||
%Chessh.Game{game | light_player: light_player, dark_player: dark_player},
|
||||
fancy
|
||||
)
|
||||
end
|
||||
|
||||
def make_status_line(
|
||||
%Chessh.Game{
|
||||
@ -143,12 +126,12 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
"Game #{game_id} - ",
|
||||
if(fancy,
|
||||
do: ANSI.format_fragment([@light_piece_color, light_player]),
|
||||
else: "#{light_player} (L)"
|
||||
else: "♔ #{light_player}"
|
||||
),
|
||||
"#{if fancy, do: ANSI.default_color(), else: ""} --vs-- ",
|
||||
if(fancy,
|
||||
do: ANSI.format_fragment([@dark_piece_color, dark_player]),
|
||||
else: "#{dark_player} (D)"
|
||||
else: "♚ #{dark_player}"
|
||||
),
|
||||
if(fancy, do: ANSI.default_color(), else: ""),
|
||||
case status do
|
||||
@ -373,4 +356,25 @@ defmodule Chessh.SSH.Client.Game.Renderer do
|
||||
Map.merge(acc, pieces_map_for_this_row)
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_players(
|
||||
%Chessh.Game{light_player: light_player, dark_player: dark_player, bot: bot} = game
|
||||
) do
|
||||
case {is_nil(light_player), is_nil(dark_player), is_nil(bot)} do
|
||||
{false, true, false} ->
|
||||
{game.light_player, %Player{username: bot.name}}
|
||||
|
||||
{true, false, false} ->
|
||||
{%Player{username: bot.name}, game.dark_player}
|
||||
|
||||
{true, false, true} ->
|
||||
{%Player{username: "(no opponent)"}, game.dark_player}
|
||||
|
||||
{false, true, true} ->
|
||||
{game.light_player, %Player{username: "(no opponent)"}}
|
||||
|
||||
{false, false, true} ->
|
||||
{game.light_player, game.dark_player}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
42
lib/chessh/ssh/client/menus/create_game.ex
Normal file
42
lib/chessh/ssh/client/menus/create_game.ex
Normal 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
|
@ -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
|
||||
|
||||
|
119
lib/chessh/ssh/client/menus/select_bot.ex
Normal file
119
lib/chessh/ssh/client/menus/select_bot.ex
Normal 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
|
@ -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 =
|
||||
|
@ -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 =
|
||||
|
@ -26,7 +26,8 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
|
||||
player_session: nil,
|
||||
options: [],
|
||||
tick: 0,
|
||||
cursor: nil
|
||||
cursor: nil,
|
||||
extra_info: %{}
|
||||
end
|
||||
|
||||
defmacro __using__(_) do
|
||||
@ -127,51 +128,55 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
|
||||
max_item = min(length(options), max_displayed_options())
|
||||
|
||||
new_state =
|
||||
if(max_item > 0,
|
||||
do:
|
||||
case action do
|
||||
:up ->
|
||||
%State{
|
||||
state
|
||||
| selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item),
|
||||
tick: 0
|
||||
}
|
||||
|
||||
:down ->
|
||||
%State{
|
||||
state
|
||||
| selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item),
|
||||
tick: 0
|
||||
}
|
||||
|
||||
:left ->
|
||||
if dynamic_options(),
|
||||
do: %State{
|
||||
if max_item > 0 do
|
||||
if action == :return do
|
||||
{_, selected} = Enum.at(options, selected_option_idx)
|
||||
{module, state} = make_process_tuple(selected, state)
|
||||
send(client_pid, {:set_screen_process, module, state})
|
||||
state
|
||||
else
|
||||
if(max_item > 1) do
|
||||
case action do
|
||||
:up ->
|
||||
%State{
|
||||
state
|
||||
| options: previous_page_options(state),
|
||||
selected_option_idx: 0,
|
||||
| selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item),
|
||||
tick: 0
|
||||
}
|
||||
|
||||
:right ->
|
||||
if dynamic_options(),
|
||||
do: %State{
|
||||
:down ->
|
||||
%State{
|
||||
state
|
||||
| options: next_page_options(state),
|
||||
selected_option_idx: 0,
|
||||
| selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item),
|
||||
tick: 0
|
||||
}
|
||||
|
||||
:return ->
|
||||
{_, selected} = Enum.at(options, selected_option_idx)
|
||||
{module, state} = make_process_tuple(selected, state)
|
||||
send(client_pid, {:set_screen_process, module, state})
|
||||
state
|
||||
:left ->
|
||||
if dynamic_options(),
|
||||
do: %State{
|
||||
state
|
||||
| options: previous_page_options(state),
|
||||
selected_option_idx: 0,
|
||||
tick: 0
|
||||
}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
:right ->
|
||||
if dynamic_options(),
|
||||
do: %State{
|
||||
state
|
||||
| options: next_page_options(state),
|
||||
selected_option_idx: 0,
|
||||
tick: 0
|
||||
}
|
||||
|
||||
_ ->
|
||||
state
|
||||
end
|
||||
else
|
||||
state
|
||||
end
|
||||
) || state
|
||||
end
|
||||
end
|
||||
|
||||
if !(action == :return) do
|
||||
render(width, height, new_state)
|
||||
@ -229,7 +234,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
|
||||
|
||||
if i == selected_option_idx do
|
||||
ANSI.format_fragment(
|
||||
[:light_cyan, :bright, "> #{line} <", :reset],
|
||||
[:light_cyan, :bright, "♜ #{line} ♜", :reset],
|
||||
true
|
||||
)
|
||||
else
|
||||
@ -238,7 +243,7 @@ defmodule Chessh.SSH.Client.SelectPaginatePoller do
|
||||
end
|
||||
)
|
||||
else
|
||||
["Looks like there's nothing here.", "Use Ctrl+b to go back."]
|
||||
["Looks like there's nothing here.", "Use Ctrl+b return to the menu."]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
11
priv/repo/migrations/20230529193453_add_citext.exs
Normal file
11
priv/repo/migrations/20230529193453_add_citext.exs
Normal 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
|
22
priv/repo/migrations/20230529193504_add_bots.exs
Normal file
22
priv/repo/migrations/20230529193504_add_bots.exs
Normal 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
|
Loading…
Reference in New Issue
Block a user