Initial persistent games

This commit is contained in:
Simponic 2023-01-15 16:58:01 -07:00
parent a1d244fe72
commit 05606beb7e
Signed by untrusted user who does not match committer: simponic
GPG Key ID: 52B3774857EB24B1
9 changed files with 393 additions and 187 deletions

42
lib/chessh/schema/game.ex Normal file
View File

@ -0,0 +1,42 @@
defmodule Chessh.Game do
alias Chessh.Player
use Ecto.Schema
import Ecto.Changeset
schema "games" do
field(:increment_sec, :integer)
field(:light_clock_ms, :integer)
field(:dark_clock_ms, :integer)
field(:last_move, :utc_datetime_usec)
field(:fen, :string)
field(:moves, :integer, default: 0)
field(:turn, Ecto.Enum, values: [:light, :dark], default: :light)
field(:winner, Ecto.Enum, values: [:light, :dark, :none], default: :none)
field(:status, Ecto.Enum, values: [:continue, :draw, :winner], default: :continue)
belongs_to(:light_player, Player, foreign_key: :light_player_id)
belongs_to(:dark_player, Player, foreign_key: :dark_player_id)
timestamps()
end
def changeset(game, attrs) do
game
|> cast(attrs, [
:fen,
:moves,
:turn,
:winner,
:status,
:last_move,
:increment_sec,
:light_clock_ms,
:dark_clock_ms,
:last_move,
:light_player_id,
:dark_player_id
])
end
end

View File

@ -1,6 +1,7 @@
defmodule Chessh.Player do
use Ecto.Schema
import Ecto.Changeset
alias Chessh.{Key, Game}
@derive {Inspect, except: [:password]}
schema "players" do
@ -11,7 +12,9 @@ defmodule Chessh.Player do
field(:authentications, :integer, default: 0)
has_many(:keys, Chessh.Key)
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)
timestamps()
end

View File

@ -1,123 +0,0 @@
defmodule Chessh.SSH.Client.Board do
require Logger
alias Chessh.Utils
alias Chessh.SSH.Client.Board.Renderer
defmodule State do
defstruct cursor: %{x: 7, y: 7},
highlighted: %{},
move_from: nil,
game_id: nil,
client_pid: nil,
binbo_pid: nil,
width: 0,
height: 0,
flipped: false
end
use Chessh.SSH.Client.Screen
def init([%State{client_pid: client_pid, game_id: game_id} = state | _]) do
:syn.add_node_to_scopes([:games])
:ok = :syn.join(:games, {:game, game_id}, self())
:binbo.start()
{:ok, binbo_pid} = :binbo.new_server()
:binbo.new_game(binbo_pid)
send(client_pid, {:send_to_ssh, Utils.clear_codes()})
{:ok, %State{state | binbo_pid: binbo_pid}}
end
def handle_info({:new_move, move}, %State{binbo_pid: binbo_pid, client_pid: client_pid} = state) do
case :binbo.move(binbo_pid, move) do
{:ok, :continue} ->
send(client_pid, {:send_to_ssh, render_state(state)})
_ ->
nil
end
{:noreply, state}
end
def input(
width,
height,
action,
%State{
move_from: move_from,
game_id: game_id,
cursor: %{x: cursor_x, y: cursor_y} = cursor,
client_pid: client_pid,
flipped: flipped
} = state
) do
new_cursor =
case action do
:left -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, -1, Renderer.chess_board_width())}
:right -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, 1, Renderer.chess_board_width())}
:down -> %{y: Utils.wrap_around(cursor_y, 1, Renderer.chess_board_height()), x: cursor_x}
:up -> %{y: Utils.wrap_around(cursor_y, -1, Renderer.chess_board_height()), x: cursor_x}
_ -> cursor
end
{new_move_from, move_to} =
if action == :return do
coords = {new_cursor.y, new_cursor.x}
case move_from do
nil -> {coords, nil}
_ -> {nil, coords}
end
else
{move_from, nil}
end
# TODO: Check move here, then publish new move, subscribers get from DB instead
if move_from && move_to do
attempted_move =
if flipped,
do:
"#{Renderer.to_chess_coord(flip(move_from))}#{Renderer.to_chess_coord(flip(move_to))}",
else: "#{Renderer.to_chess_coord(move_from)}#{Renderer.to_chess_coord(move_to)}"
:syn.publish(:games, {:game, game_id}, {:new_move, attempted_move})
end
new_state = %State{
state
| cursor: new_cursor,
move_from: new_move_from,
highlighted: %{
{new_cursor.y, new_cursor.x} => Renderer.to_select_background(),
new_move_from => Renderer.from_select_background()
},
width: width,
height: height,
flipped: if(action == "f", do: !flipped, else: flipped)
}
send(client_pid, {:send_to_ssh, render_state(new_state)})
new_state
end
def render(width, height, %State{client_pid: client_pid} = state) do
send(client_pid, {:send_to_ssh, render_state(state)})
%State{state | width: width, height: height}
end
def flip({y, x}),
do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x}
defp render_state(
%State{
binbo_pid: binbo_pid
} = state
) do
{:ok, fen} = :binbo.get_fen(binbo_pid)
Renderer.render_board_state(fen, state)
end
end

View File

@ -24,8 +24,13 @@ defmodule Chessh.SSH.Client do
end
@impl true
def init([%State{} = state]) do
send(self(), {:set_screen_process, Chessh.SSH.Client.Menu, %Chessh.SSH.Client.Menu.State{}})
def init([%State{player_session: player_session} = state]) do
send(
self(),
{:set_screen_process, Chessh.SSH.Client.Menu,
%Chessh.SSH.Client.Menu.State{player_session: player_session}}
)
{:ok, state}
end

View File

@ -0,0 +1,255 @@
defmodule Chessh.SSH.Client.Game do
require Logger
alias Chessh.{Game, Utils, Repo}
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: %{},
move_from: nil,
game: nil,
client_pid: nil,
binbo_pid: nil,
width: 0,
height: 0,
flipped: false,
color: :light,
player_session: nil
end
use Chessh.SSH.Client.Screen
defp initialize_game(game_id, fen) do
:syn.add_node_to_scopes([:games])
:ok = :syn.join(:games, {:game, game_id}, self())
:binbo.start()
{:ok, binbo_pid} = :binbo.new_server()
:binbo.new_game(binbo_pid, fen)
binbo_pid
end
def init([
%State{
color: color,
game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id}
} = state
| tail
])
when is_nil(color) do
new_state =
case {is_nil(dark_player_id), is_nil(light_player_id)} do
{true, false} -> %State{state | color: :dark}
{false, true} -> %State{state | color: :light}
{_, _} -> %State{state | color: Enum.random([:light, :dark])}
end
init([new_state | tail])
end
def init([
%State{
player_session: player_session,
color: color,
client_pid: client_pid,
game:
%Game{
id: game_id,
fen: fen,
dark_player_id: dark_player_id,
light_player_id: light_player_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})
:dark ->
if !dark_player_id,
do: Game.changeset(game, %{dark_player_id: player_session.player_id})
end
{status, maybe_new_game} =
if maybe_changeset do
maybe_changeset
|> Repo.update()
else
{:undefined, nil}
end
new_game =
case {status, maybe_new_game} do
{:ok, g} -> g
_ -> game
end
binbo_pid = initialize_game(game_id, fen)
send(client_pid, {:send_to_ssh, Utils.clear_codes()})
{:ok,
%State{
state
| binbo_pid: binbo_pid,
color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark)
}}
end
def init([
%State{player_session: player_session, color: color, client_pid: client_pid, game: nil} =
state
| _
]) do
{:ok, %Game{id: game_id, fen: fen} = 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,
increment_sec: 3,
light_clock_ms: 5 * 60 * 1000,
dark_clock_ms: 5 * 60 * 1000
}
)
)
|> Repo.insert()
binbo_pid = initialize_game(game_id, fen)
send(client_pid, {:send_to_ssh, Utils.clear_codes()})
{:ok, %State{state | game: game, binbo_pid: binbo_pid}}
end
def handle_info(
{:new_move, move},
%State{game: %Game{id: game_id}, client_pid: client_pid, binbo_pid: binbo_pid} = state
) do
:binbo.move(binbo_pid, move)
new_state = %State{state | game: Repo.get(Game, game_id)}
send(client_pid, {:send_to_ssh, render_state(new_state)})
{:noreply, new_state}
end
def input(
width,
height,
action,
%State{
move_from: move_from,
cursor: %{x: cursor_x, y: cursor_y} = cursor,
client_pid: client_pid,
flipped: flipped
} = state
) do
new_cursor =
case action do
:left -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, -1, Renderer.chess_board_width())}
:right -> %{y: cursor_y, x: Utils.wrap_around(cursor_x, 1, Renderer.chess_board_width())}
:down -> %{y: Utils.wrap_around(cursor_y, 1, Renderer.chess_board_height()), x: cursor_x}
:up -> %{y: Utils.wrap_around(cursor_y, -1, Renderer.chess_board_height()), x: cursor_x}
_ -> cursor
end
{new_move_from, move_to} =
if action == :return do
coords = {new_cursor.y, new_cursor.x}
case move_from do
nil -> {coords, nil}
_ -> {nil, coords}
end
else
{move_from, nil}
end
if move_from && move_to do
attempt_move(move_from, move_to, state)
end
new_state = %State{
state
| cursor: new_cursor,
move_from: new_move_from,
highlighted: %{
{new_cursor.y, new_cursor.x} => Renderer.to_select_background(),
new_move_from => Renderer.from_select_background()
},
width: width,
height: height,
flipped: if(action == "f", do: !flipped, else: flipped)
}
send(client_pid, {:send_to_ssh, render_state(new_state)})
new_state
end
def render(width, height, %State{client_pid: client_pid} = state) do
new_state = %State{state | width: width, height: height}
send(client_pid, {:send_to_ssh, render_state(new_state)})
new_state
end
defp attempt_move(
from,
to,
%State{
game: %Game{id: game_id, turn: turn},
binbo_pid: binbo_pid,
flipped: flipped,
color: turn
}
) do
attempted_move =
if flipped,
do: "#{Renderer.to_chess_coord(flip(from))}#{Renderer.to_chess_coord(flip(to))}",
else: "#{Renderer.to_chess_coord(from)}#{Renderer.to_chess_coord(to)}"
case :binbo.move(binbo_pid, attempted_move) do
{:ok, :continue} ->
{:ok, fen} = :binbo.get_fen(binbo_pid)
game = Repo.get(Game, game_id)
{:ok, _new_game} =
Game.changeset(game, %{
fen: fen,
moves: game.moves + 1,
turn: if(game.turn == :dark, do: :light, else: :dark),
last_move: DateTime.utc_now()
})
|> Repo.update()
:syn.publish(:games, {:game, game_id}, {:new_move, attempted_move})
_ ->
nil
end
end
defp attempt_move(_, _, _) do
Logger.debug("No matching clause for move attempt - must be illegal?")
nil
end
defp flip({y, x}),
do: {Renderer.chess_board_height() - 1 - y, Renderer.chess_board_width() - 1 - x}
defp render_state(
%State{
game: %Game{fen: fen}
} = state
) do
Renderer.render_board_state(fen, state)
end
end

View File

@ -1,7 +1,7 @@
defmodule Chessh.SSH.Client.Board.Renderer do
defmodule Chessh.SSH.Client.Game.Renderer do
alias IO.ANSI
alias Chessh.Utils
alias Chessh.SSH.Client.Board
alias Chessh.SSH.Client.Game
require Logger
@chess_board_height 8
@ -25,7 +25,7 @@ defmodule Chessh.SSH.Client.Board.Renderer do
"#{List.to_string([?a + x])}#{@chess_board_height - y}"
end
def render_board_state(fen, %Board.State{
def render_board_state(fen, %Game.State{
width: _width,
height: _height,
highlighted: highlighted,

View File

@ -1,12 +1,15 @@
defmodule Chessh.SSH.Client.Menu do
alias Chessh.Utils
alias IO.ANSI
alias Chessh.{Utils, Repo, Game}
import Ecto.Query
require Logger
defmodule State do
defstruct client_pid: nil,
selected: 0
selected: 0,
player_session: nil,
options: []
end
@logo " Simponic's
@ -20,31 +23,70 @@ defmodule Chessh.SSH.Client.Menu do
use Chessh.SSH.Client.Screen
def init([%State{} = state | _]) do
{:ok, state}
{:ok, %State{state | options: options(state)}}
end
@options [
{"Start A Game", {Chessh.SSH.Client.Board, %Chessh.SSH.Client.Board.State{}}},
{"Join A Game", {}},
{"My Games", {}},
{"Settings", {}},
{"Help", {}}
]
def options(%State{player_session: player_session}) do
current_games =
Repo.all(
from(g in Game,
where: g.light_player_id == ^player_session.player_id,
or_where: g.dark_player_id == ^player_session.player_id,
where: g.status == :continue
)
)
def input(width, height, action, %State{client_pid: client_pid, selected: selected} = state) do
joinable_games =
Repo.all(
from(g in Game,
where: is_nil(g.light_player_id),
or_where: is_nil(g.dark_player_id)
)
)
[
{"Start A Game As Light",
{Chessh.SSH.Client.Game,
%Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}},
{"Start A Game As Dark",
{Chessh.SSH.Client.Game,
%Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}}
] ++
Enum.map(current_games, fn game ->
{"Current Game - #{game.id}",
{Chessh.SSH.Client.Game,
%Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}}
end) ++
Enum.map(joinable_games, fn game ->
{"Joinable Game - #{game.id}",
{Chessh.SSH.Client.Game,
%Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}}
end) ++
[
{"Settings", {}},
{"Help", {}}
]
end
def input(
width,
height,
action,
%State{options: options, client_pid: client_pid, selected: selected} = state
) do
new_state =
case(action) do
:up ->
%State{
state
| selected: Utils.wrap_around(selected, -1, length(@options))
| selected: Utils.wrap_around(selected, -1, length(options))
}
:down ->
%State{state | selected: Utils.wrap_around(selected, 1, length(@options))}
%State{state | selected: Utils.wrap_around(selected, 1, length(options))}
:return ->
{_option, {module, state}} = Enum.at(@options, selected)
{_option, {module, state}} = Enum.at(options, selected)
send(client_pid, {:set_screen_process, module, state})
state
@ -67,7 +109,7 @@ defmodule Chessh.SSH.Client.Menu do
defp render_state(
width,
height,
%State{selected: selected}
%State{options: options, selected: selected} = state
) do
logo_lines = String.split(@logo, "\n")
{logo_width, logo_height} = Utils.text_dim(@logo)
@ -84,7 +126,7 @@ defmodule Chessh.SSH.Client.Menu do
end
) ++
Enum.flat_map(
Enum.zip(0..(length(@options) - 1), @options),
Enum.zip(0..(length(options) - 1), options),
fn {i, {option, _}} ->
[
ANSI.cursor(y + length(logo_lines) + i + 1, x),

View File

@ -144,7 +144,6 @@ defmodule Chessh.SSH.Tui do
{:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}},
%{width: width, height: height, player_session: player_session} = state
) do
Logger.debug("Session #{player_session.id} requested shell")
:ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id)
{:ok, client_pid} =
@ -161,47 +160,6 @@ defmodule Chessh.SSH.Tui do
{:ok, %State{state | client_pid: client_pid}}
end
def handle_ssh_msg(
{:ssh_cm, connection_handler, {:exec, channel_id, want_reply?, cmd}},
%State{} = state
) do
:ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id)
Logger.debug("EXEC #{cmd}")
{:ok, state}
end
def handle_ssh_msg(
{:ssh_cm, _connection_handler, {:eof, _channel_id}},
%State{} = state
) do
Logger.debug("EOF")
{:ok, state}
end
def handle_ssh_msg(
{:ssh_cm, _connection_handler, {:signal, _channel_id, signal}},
%State{} = state
) do
Logger.debug("SIGNAL #{signal}")
{:ok, state}
end
def handle_ssh_msg(
{:ssh_cm, _connection_handler, {:exit_signal, channel_id, signal, err, lang}},
%State{} = state
) do
Logger.debug("EXIT SIGNAL #{signal} #{err} #{lang}")
{:stop, channel_id, state}
end
def handle_ssh_msg(
{:ssh_cm, _connection_handler, {:exit_STATUS, channel_id, status}},
%State{} = state
) do
Logger.debug("EXIT STATUS #{status}")
{:stop, channel_id, state}
end
def handle_ssh_msg(
msg,
%State{channel_id: channel_id} = state

View File

@ -0,0 +1,24 @@
defmodule Chessh.Repo.Migrations.CreateGames do
use Ecto.Migration
def change do
create table(:games) do
add(:increment_sec, :integer)
add(:light_clock_ms, :integer)
add(:dark_clock_ms, :integer)
add(:last_move, :utc_datetime_usec, null: true)
add(:fen, :string)
add(:moves, :integer, default: 0)
add(:turn, :string)
add(:winner, :string)
add(:status, :string)
add(:light_player_id, references(:players), null: true)
add(:dark_player_id, references(:players), null: true)
timestamps()
end
end
end