From 83ff8ba8a086ca10a7e68e04d1f8dc43eed789ec Mon Sep 17 00:00:00 2001 From: Lukas Pichlmann Date: Fri, 16 Sep 2022 22:57:11 +0200 Subject: [PATCH] version 1.0.0 --- .gitignore | 6 + README.md | 231 +++++++++- autoload/lichess/board_setup.vim | 155 +++++++ autoload/lichess/play.vim | 725 +++++++++++++++++++++++++++++++ autoload/lichess/util.vim | 18 + plugin/lichess.vim | 65 +++ python/play_game.py | 234 ++++++++++ python/server.py | 422 ++++++++++++++++++ python/util.py | 182 ++++++++ 9 files changed, 2037 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 autoload/lichess/board_setup.vim create mode 100644 autoload/lichess/play.vim create mode 100644 autoload/lichess/util.vim create mode 100644 plugin/lichess.vim create mode 100644 python/play_game.py create mode 100644 python/server.py create mode 100644 python/util.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd6aee1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.jukit/ +mylichesstoken.py +fen_test_cases.py +log/ +__pycache__ +.debug_level diff --git a/README.md b/README.md index f79cc66..0354be8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,231 @@ # vim-lichess -Play lichess in (neo)vim! + +Play online chess in (Neo)Vim! + +![vimlichessdemo](https://user-images.githubusercontent.com/57172028/190704946-4708be17-83c0-4652-ae3e-9cb958faa557.gif) + +### Why? + +Because why not. Not having to leave (Neo)Vim to play online chess should be a basic human right. + +### Honestly, why? + +Honestly, because why not. + +## Requirements + +* (Neo)Vim with python3 support +* `berserk` package for python3 (install via `pip install berserk`) +* A lichess account + +## Basic setup and how to play + +Install the plugin using e.g. `Plug 'luk400/vim-lichess'` in case you're using vim-plug. + +Open (Neo)Vim and run `:LichessFindGame`. You'll be prompted with instructions on how to create and specify your lichess API token in your vim config if you haven't yet (see first variable in section [Other parameters](#other-parameters)). If you've already set your API Token, a new buffer will open and a new game will be started. + +***You can then play by simply left-clicking on a piece and then right-clicking on its destination square***, or alternatively by typing the move in UCI format ([see this link for examples](https://en.wikipedia.org/wiki/Universal_Chess_Interface#Design)) after using the `LichessMakeMoveUCI` command. + +For other actions, such as resigning, offering draws, takebacks, chatting, etc. see section [Commands and mappings](#commands-and-mappings) + +When the game is over, you can either start a new game again using `:LichessFindGame` or delete the buffer using e.g. `:bd` and get back to whatever you've been doing before. + +## Commands and mappings + +#### Commands +* `:LichessFindGame`: Find a new game using [the parameters specified in your vim config](#game-parameters) +* `:LichessResign`: Resign a game +* `:LichessAbort`: Abort a game +* `:LichessClaimVictory`: Claim victory if opponent has abandoned the game (unfortunately there's no way to determine whether a game is "claimable" through lichess API, thus you'll just have to try by running the command when you think the opponent might've abandoned the game) +* `:LichessDrawDecline`: Create or accept a draw offer +* `:LichessDrawOfferAccept`: Decline a draw offer +* `:LichessTakebackOfferAccept`: Create or accept a takeback offer +* `:LichessTakebackOfferDecline`: Decline a takeback offer +* `:LichessMakeMoveUCI`: type a move to make in UCI format ([see this link for examples](https://en.wikipedia.org/wiki/Universal_Chess_Interface#Design)) +* `:LichessChat`: write a message in chat (note that your messages won't register if you're shadowbanned) + +#### Mappings +```vim +nnoremap lm :LichessMakeMoveUCI +nnoremap lc :LichessChat +nnoremap la :LichessAbort +nnoremap lr :LichessResign +nnoremap ldo :LichessOfferDraw +nnoremap lda :LichessAcceptDraw +nnoremap ldd :LichessDeclineDraw +nnoremap ch :call lichess#play#find_game() +``` + +## Global variables + +#### Game parameters +```vim +let g:lichess_autoqueen = 1 +" whether to automatically promote to queen or not +let g:lichess_time = 10 +" game time in minutes - must be >= 8, since lichess API only allows rapid or classical games +let g:lichess_increment = 0 +" increment in seconds +let g:lichess_rated = 1 +" whether to play rated games (1) or unrated games (0) +let g:lichess_variant = "standard" +" lichess variant to play -> this plugin has currently only been tested with 'standard'! possible values: ['standard', 'chess960', 'crazyhouse', 'antichess', 'atomic', 'horde', 'kingOfTheHill', 'racingKings', 'threeCheck'] +let g:lichess_color = "random" +" which color you want to play as. possible values: ['white', 'black', 'random'] +let g:lichess_rating_range = [] +" rating range of your opponents, can be an empty list to use the default (recommended) or a list like `[low,high]`, where `low` and `high` are integers. +``` + +#### Other parameters +```vim +let g:lichess_api_token = '' +" your required lichess API token. you can easily easily create one which you can put in your config using this link: https://lichess.org/account/oauth/token/create?scopes[]=challenge:write&scopes[]=board:play&description=vim+lichess +let g:python_cmd = 'python3' +" python command to run server in background - this should be the python executable for which berserk is installed (can also be a full path) +let g:lichess_debug_level = -1 +" set debugging level. -1 means nothing is logged and no log files are created, 0 -> all info is logged, 1 -> only warnings and 'worse' are logged, 2 -> only errors and 'worse' are logged, 3 -> only crashes are logged +``` + +#### Highlighting + +In case you want change the board colors or other highlighting options, you can modify any of the following highlights and put them in your vim config (AFTER the plugin is loaded - e.g. after `call plug#end()` in case you're using vim-plug) to overwrite them: + +```vim +highlight lichess_black_squares guibg=#B58863 ctermbg=94 +" highlighting of black squares +highlight lichess_white_squares guibg=#F0D9B5 ctermbg=7 +" highlighting of white squares +highlight lichess_black_pieces guifg=#000000 guibg=#000000 ctermbg=0 ctermfg=0 +" highlighting of black pieces +highlight lichess_white_pieces guifg=#ffffff guibg=#ffffff ctermbg=15 ctermfg=15 +" highlighting of white pieces +highlight lichess_from_square_dark guifg=#AAA23A guibg=#AAA23A ctermbg=172 ctermfg=172 +" highlighting of the previous and new square of the latest moved piece if it's a dark square +highlight lichess_from_square_light guifg=#CDD26A guibg=#CDD26A ctermbg=178 ctermfg=178 +" highlighting of the previous and new square of the latest moved piece if it's a light square +highlight lichess_cell_delimiters guifg=#000000 guibg=#000000 ctermbg=0 ctermfg=0 +" highlighting of vertical cell delimiters between squares +highlight lichess_user_turn guibg=#3eed6c guifg=#000000 ctermbg=2 ctermfg=0 cterm=bold gui=bold +" highlighting of the name of the user whose turn it currently is +highlight lichess_user_noturn guifg=#ffffff ctermfg=15 cterm=bold gui=bold +" highlighting of the name of the user whose turn it's currently not +highlight lichess_searching_game guifg=#42d7f5 guibg=#000000 ctermfg=14 ctermbg=0 cterm=bold gui=bold +" highlighting of 'searching game...' prompt +highlight lichess_game_ended guibg=#e63c30 guifg=#ffffff ctermbg=1 ctermfg=15 cterm=bold gui=bold +" highlighting of last game status (e.g. 'MATE' or 'RESIGN') +highlight lichess_chat guibg=#e3f27e guifg=#000000 ctermbg=191 ctermfg=0 +" highlighting of of opponent chat messages +highlight lichess_chat_system guibg=#ed8787 guifg=#000000 ctermbg=178 ctermfg=0 +" highlighting of of lichess chat messages +highlight lichess_chat_you guibg=#b4e364 guifg=#000000 ctermbg=190 ctermfg=0 +" highlighting of your chat messages +highlight lichess_chat_bold guibg=#e3f27e guifg=#000000 ctermbg=191 ctermfg=0 cterm=bold gui=bold +" highlighting of of 'CHAT:' prompt +highlight lichess_move_info guibg=#9ea832 guifg=#000000 ctermbg=3 ctermfg=0 cterm=bold gui=bold +" echohl highlighting of echoed move-message +highlight lichess_too_many_requests guibg=#c20202 guifg=#ffffff ctermbg=15 ctermfg=9 cterm=bold gui=bold +" echohl highlighting of too_many_requests error +``` + +#### Piece representation + +In case you don't like my piece design, you can design your own as shown below. +You can also change their width/height (number of characters in strings/number of strings in list) to make them bigger if you want more detail, as long as you follow the following restrictions: +* all pieces must have the same height (number of strings in piece list) +* all pieces must have the same width (number of characters in the strings) +* there must be exactly one unique non-whitespace character for all black and one unique non-whitespace character for all white pieces. This can not be the same for the white and black pieces and it must have a length of 1 (there are certain characters which have a different length in vim - e.g.: `echo len('║')` will print `3` even though it's a single character) + + +```vim +" black pieces +let g:lichess_piece_p = + \ [" ", + \ " ,, ", + \ " ,,,, ", + \ " ,,,, ", + \ " ,,,, ", + \ " "] " black pawn +let g:lichess_piece_r = + \ [" ", + \ " , ,, , ", + \ " ,,,,,, ", + \ " ,,,,,, ", + \ " ,,,,,,,, ", + \ " "] " black rook +let g:lichess_piece_k = + \ [" ", + \ " ,, ", + \ " ,,,,,,,, ", + \ " ,, ", + \ " ,, ", + \ " "] " black king +let g:lichess_piece_q = + \ [" ", + \ " , ,, , ", + \ " ,,,, ", + \ " ,, ", + \ " ,,,,,, ", + \ " "] " black queen +let g:lichess_piece_b = + \ [" ", + \ " ,,,, ", + \ " ,,,, ", + \ " ,, ", + \ " ,,,,,, ", + \ " "] " black bishop +let g:lichess_piece_n = + \ [" ", + \ " ,,, ", + \ " ,,, ,, ", + \ " ,,, ", + \ " ,,,,,, ", + \ " "] " black knight + +" white pieces +let g:lichess_piece_P = + \ [" ", + \ " ;; ", + \ " ;;;; ", + \ " ;;;; ", + \ " ;;;; ", + \ " "] " white pawn +let g:lichess_piece_R = + \ [" ", + \ " ; ;; ; ", + \ " ;;;;;; ", + \ " ;;;;;; ", + \ " ;;;;;;;; ", + \ " "] " white rook +let g:lichess_piece_K = + \ [" ", + \ " ;; ", + \ " ;;;;;;;; ", + \ " ;; ", + \ " ;; ", + \ " "] " white king +let g:lichess_piece_Q = + \ [" ", + \ " ; ;; ; ", + \ " ;;;; ", + \ " ;; ", + \ " ;;;;;; ", + \ " "] " white queen +let g:lichess_piece_B = + \ [" ", + \ " ;;;; ", + \ " ;;;; ", + \ " ;; ", + \ " ;;;;;; ", + \ " "] " white bishop +let g:lichess_piece_N = + \ [" ", + \ " ;;; ", + \ " ;;; ;; ", + \ " ;;; ", + \ " ;;;;;; ", + \ " "] " white knight +``` + +# Credit + +All credit goes to my huge procrastination issues diff --git a/autoload/lichess/board_setup.vim b/autoload/lichess/board_setup.vim new file mode 100644 index 0000000..a316394 --- /dev/null +++ b/autoload/lichess/board_setup.vim @@ -0,0 +1,155 @@ +"""""""""""""""""""" +" highlighting setup +"""""""""""""""""""" +let params = lichess#play#get_square_dim_and_piece_chars() +let s:square_width = params[0] +let s:square_height = params[1] +let s:white_piece_char = params[2] +let s:black_piece_char = params[3] +let s:start_wcell = '`' +let s:start_bcell = "'" +let s:move_cell_dark = '-' +let s:move_cell_light = '_' + +if !hlexists('lichess_cell_delimiters') + highlight lichess_cell_delimiters guifg=#000000 guibg=#000000 ctermbg=0 ctermfg=0 +endif +if !hlexists('lichess_black_squares') + highlight lichess_black_squares guibg=#B58863 ctermbg=94 +endif +if !hlexists('lichess_white_squares') + highlight lichess_white_squares guibg=#F0D9B5 ctermbg=7 +endif +if !hlexists('lichess_black_pieces') + highlight lichess_black_pieces guifg=#000000 guibg=#000000 ctermbg=0 ctermfg=0 +endif +if !hlexists('lichess_white_pieces') + highlight lichess_white_pieces guifg=#ffffff guibg=#ffffff ctermbg=15 ctermfg=15 +endif +if !hlexists('lichess_from_square_dark') + highlight lichess_from_square_dark guifg=#AAA23A guibg=#AAA23A ctermbg=172 ctermfg=172 +endif +if !hlexists('lichess_from_square_light') + highlight lichess_from_square_light guifg=#CDD26A guibg=#CDD26A ctermbg=178 ctermfg=178 +endif + +let s:empty_line = repeat(' ', s:square_width) +let s:empty_line_move_dark = repeat(s:move_cell_dark, s:square_width) +let s:empty_line_move_light = repeat(s:move_cell_light, s:square_width) + +fun! lichess#board_setup#syntax_matching() abort + syn clear + + exe 'syn match lichess_cell_delimiters /' . s:start_wcell . '/ contains=ALL containedin=ALL' + exe 'syn match lichess_cell_delimiters /' . s:start_bcell . '/ contains=ALL containedin=ALL' + + exe 'syn match lichess_black_squares /' . s:start_bcell . '.\{-}' . s:start_wcell . '/ containedin=ALL' + exe 'syn match lichess_white_squares /' . s:start_wcell . '.\{-}' . s:start_bcell . '/ containedin=ALL' + + exe 'syn match lichess_black_pieces /' . s:black_piece_char . '/ containedin=lichess_black_squares,lichess_white_squares' + exe 'syn match lichess_white_pieces /' . s:white_piece_char . '/ containedin=lichess_black_squares,lichess_white_squares' + + exe 'syn match lichess_from_square_dark /' . s:move_cell_dark . '/ containedin=lichess_black_squares,lichess_white_squares' + exe 'syn match lichess_from_square_light /' . s:move_cell_light . '/ containedin=lichess_black_squares,lichess_white_squares' +endfun + + +"""""""""""""""" +" board creation +"""""""""""""""" +let s:piece_symbols = { + \ 'p': p, + \ 'r': r, + \ 'k': k, + \ 'q': q, + \ 'b': b, + \ 'n': n, + \ 'P': P, + \ 'R': R, + \ 'K': K, + \ 'Q': Q, + \ 'B': B, + \ 'N': N, + \ } + +function! s:create_board(fen, latest_move) abort + " example FEN: + " rn2k1r1/ppp1pp1p/3p2p1/5bn1/P7/2N2B2/1PPPPP2/2BNK1RR + + if a:latest_move != "None" + let from_row = a:latest_move[0] - 1 + let from_column = a:latest_move[1] - 1 + let to_row = a:latest_move[2] - 1 + let to_column = a:latest_move[3] - 1 + else + let from_row = -1 + let from_column = -1 + let to_row = -1 + let to_column = -1 + endif + + let board = repeat(s:start_wcell, 9 + s:square_width * 8) . "\n" + let i = 0 + for str in split(a:fen, '/') + " rn2k1r1 + for j in range(s:square_height) + let n = 0 + for char in str + " r + let next_cell_black = fmod(i + n, 2) == 0.0 + let is_move_cell = (i == from_row) && (n == from_column) || (i == to_row) && (n == to_column) + if next_cell_black + let board = board . s:start_wcell + else + let board = board . s:start_bcell + endif + + if str2float(char) > 0 + for k in range(char) + if is_move_cell && !next_cell_black + let board = board . s:empty_line_move_dark + elseif is_move_cell + let board = board . s:empty_line_move_light + else + let board = board . s:empty_line + endif + + if next_cell_black && (k < char - 1) + let board = board . s:start_bcell + elseif (k < char - 1) + let board = board . s:start_wcell + endif + let n += 1 + let next_cell_black = fmod(i + n, 2) == 0.0 + let is_move_cell = (i == from_row) && (n == from_column) || (i == to_row) && (n == to_column) + endfor + else + if is_move_cell && !next_cell_black + let board = board . substitute(s:piece_symbols[char][j], ' ', s:move_cell_dark, 'g') + elseif is_move_cell + let board = board . substitute(s:piece_symbols[char][j], ' ', s:move_cell_light, 'g') + else + let board = board . s:piece_symbols[char][j] + endif + let n += 1 + endif + endfor + + if fmod(i, 2) == 0.0 + let board = board . s:start_wcell . "\n" + else + let board = board . s:start_bcell . "\n" + endif + endfor + let i += 1 + endfor + let board = board . repeat(s:start_wcell, 9 + s:square_width * 8) + + return board +endfunction + + +function! lichess#board_setup#display_board(fen, latest_move) abort + let board = s:create_board(a:fen, a:latest_move) + call append(0, split(board, '\n')) +endfunction diff --git a/autoload/lichess/play.vim b/autoload/lichess/play.vim new file mode 100644 index 0000000..191d758 --- /dev/null +++ b/autoload/lichess/play.vim @@ -0,0 +1,725 @@ +""""""""""""""""""" +" get piece symbols +""""""""""""""""""" +let p = get(g:, 'lichess_piece_p', + \ [" ", + \ " ,, ", + \ " ,,,, ", + \ " ,,,, ", + \ " ,,,, ", + \ " "]) +let r = get(g:, 'lichess_piece_r', + \ [" ", + \ " , ,, , ", + \ " ,,,,,, ", + \ " ,,,,,, ", + \ " ,,,,,,,, ", + \ " "]) +let k = get(g:, 'lichess_piece_k', + \ [" ", + \ " ,, ", + \ " ,,,,,,,, ", + \ " ,, ", + \ " ,, ", + \ " "]) +let q = get(g:, 'lichess_piece_q', + \ [" ", + \ " , ,, , ", + \ " ,,,, ", + \ " ,, ", + \ " ,,,,,, ", + \ " "]) +let b = get(g:, 'lichess_piece_b', + \ [" ", + \ " ,,,, ", + \ " ,,,, ", + \ " ,, ", + \ " ,,,,,, ", + \ " "]) +let n = get(g:, 'lichess_piece_n', + \ [" ", + \ " ,,, ", + \ " ,,, ,, ", + \ " ,,, ", + \ " ,,,,,, ", + \ " "]) + +let P = get(g:, 'lichess_piece_P', + \ [" ", + \ " ;; ", + \ " ;;;; ", + \ " ;;;; ", + \ " ;;;; ", + \ " "]) +let R = get(g:, 'lichess_piece_R', + \ [" ", + \ " ; ;; ; ", + \ " ;;;;;; ", + \ " ;;;;;; ", + \ " ;;;;;;;; ", + \ " "]) +let K = get(g:, 'lichess_piece_K', + \ [" ", + \ " ;; ", + \ " ;;;;;;;; ", + \ " ;; ", + \ " ;; ", + \ " "]) +let Q = get(g:, 'lichess_piece_Q', + \ [" ", + \ " ; ;; ; ", + \ " ;;;; ", + \ " ;; ", + \ " ;;;;;; ", + \ " "]) +let B = get(g:, 'lichess_piece_B', + \ [" ", + \ " ;;;; ", + \ " ;;;; ", + \ " ;; ", + \ " ;;;;;; ", + \ " "]) +let N = get(g:, 'lichess_piece_N', + \ [" ", + \ " ;;; ", + \ " ;;; ;; ", + \ " ;;; ", + \ " ;;;;;; ", + \ " "] + \) + + +""""""""""""""""""""""""""""""""" +" check validity of piece symbols +""""""""""""""""""""""""""""""""" +let black_pieces = [p, r, k, q, b, n] +let white_pieces = [P, R, K, Q, B, N] +let i = 0 +for piece_set in [black_pieces, white_pieces] + for piece in piece_set + let str_concat = join(split(join(piece)), '') + let all_chars = split(str_concat, '\zs') + let unique_chars = filter(copy(all_chars), 'index(all_chars, v:val, v:key+1)==-1') + let err_msg = 'Error: all piece representations must contain exactly one ' + \ . 'unique character (ignoring whitespaces), and there must be exactly ' + \ . 'one such character for the white and one for the black pieces!' + if !(len(unique_chars) == 1) + echohl ErrorMsg | echom err_msg | echohl None + finish + endif + + if i==0 && !exists('s:black_piece_char') + let s:black_piece_char = unique_chars[0] + elseif i==0 && unique_chars[0] != s:black_piece_char + echohl ErrorMsg | echom err_msg | echohl None + finish + elseif i==1 && !exists('s:white_piece_char') + let s:white_piece_char = unique_chars[0] + elseif i==1 && unique_chars[0] != s:white_piece_char + echohl ErrorMsg | echom err_msg | echohl None + finish + endif + + let h = len(piece) + + let err_msg = 'Error: all piece representations must have the same height!' + if !exists('s:square_height') + let s:square_height = h + elseif h != s:square_height + echohl ErrorMsg | echom err_msg | echohl None + finish + endif + + for l in piece + let w = len(l) + if !exists('s:square_width') + let s:square_width = w + elseif w != s:square_width + echohl ErrorMsg | echom err_msg | echohl None + finish + endif + endfor + endfor + let i += 1 +endfor + +if s:black_piece_char == s:white_piece_char + let err_msg = "Error: white piece character and black piece character can't be the same!" + echohl ErrorMsg | echom err_msg | echohl None + finish +elseif len(s:black_piece_char) != 1 + let err_msg = "Error: Only piece characters with length 1 allowed (check via `len`" + \ . " in vim), otherwise the cursor position can't be determined correctly! " + \ . "Current character for black pieces (" . s:black_piece_char . "): " + \ . "len(" . s:black_piece_char . ")=" . len(s:black_piece_char) + echohl ErrorMsg | echom err_msg | echohl None + finish +elseif len(s:white_piece_char) != 1 + let err_msg = "Error: Only piece characters with length 1 allowed (check via `len`" + \ . " in vim), otherwise the cursor position can't be determined correctly! " + \ . "Current character for white pieces (" . s:white_piece_char . "): " + \ . "len(" . s:white_piece_char . ")=" . len(s:white_piece_char) + echohl ErrorMsg | echom err_msg | echohl None + finish +endif + +fun! lichess#play#get_square_dim_and_piece_chars() abort + return [s:square_width, s:square_height, s:white_piece_char, s:black_piece_char] +endfun + + +""""""""""""""""""""""""""""""""""""""""" +" other needed variables and highlighting +""""""""""""""""""""""""""""""""""""""""" +let s:lichess_fen = "None" +let s:hl_was_set = 0 +let yoffset = 2 +let xoffset = 0 + +if !hlexists('lichess_move_info') + highlight lichess_move_info guibg=#9ea832 guifg=#000000 ctermbg=3 ctermfg=0 cterm=bold gui=bold +endif +if !hlexists('lichess_too_many_requests') + highlight lichess_too_many_requests guibg=#c20202 guifg=#ffffff ctermbg=15 ctermfg=9 cterm=bold gui=bold +endif +if !hlexists('lichess_user_turn') + highlight lichess_user_turn guibg=#3eed6c guifg=#000000 ctermbg=2 ctermfg=0 cterm=bold gui=bold +endif +if !hlexists('lichess_user_noturn') + highlight lichess_user_noturn guifg=#ffffff ctermfg=15 cterm=bold gui=bold +endif +if !hlexists('lichess_searching_game') + highlight lichess_searching_game guifg=#42d7f5 guibg=#000000 ctermfg=14 ctermbg=0 cterm=bold gui=bold +endif +if !hlexists('lichess_game_ended') + highlight lichess_game_ended guibg=#e63c30 guifg=#ffffff ctermbg=1 ctermfg=15 cterm=bold gui=bold +endif +if !hlexists('lichess_chat') + highlight lichess_chat guibg=#e3f27e guifg=#000000 ctermbg=191 ctermfg=0 +endif +if !hlexists('lichess_chat_system') + highlight lichess_chat_system guibg=#ed8787 guifg=#000000 ctermbg=178 ctermfg=0 +endif +if !hlexists('lichess_chat_bold') + highlight lichess_chat_bold guibg=#e3f27e guifg=#000000 ctermbg=191 ctermfg=0 cterm=bold gui=bold +endif +if !hlexists('lichess_chat_you') + highlight lichess_chat_you guibg=#b4e364 guifg=#000000 ctermbg=190 ctermfg=0 +endif + + +"""""""""""""""""""""""""""""""""""""""""""""""""""""" +" dictionaries to map cursor position to board squares +"""""""""""""""""""""""""""""""""""""""""""""""""""""" +let s:line_squareline_map_black = {} +for idx in range(1, s:square_height * 8) + let s:line_squareline_map_black[yoffset + 1 + idx] = float2nr(ceil(str2float(idx) / s:square_height)) +endfor +let s:line_squareline_map_black[yoffset + 1] = 8 +let s:line_squareline_map_black[s:square_height * 8 + yoffset + 2] = 1 + +let s:line_squareline_map_white = {} +for idx in range(1, s:square_height * 8) + let s:line_squareline_map_white[yoffset + 1 + idx] = 9 - float2nr(ceil(str2float(idx) / s:square_height)) +endfor +let s:line_squareline_map_white[yoffset + 1] = 1 +let s:line_squareline_map_white[s:square_height * 8 + yoffset + 2] = 8 + + +let s:col_squarecol_map_white = {} +for idx in range(1, 8 + s:square_width * 8) + let s:col_squarecol_map_white[xoffset + idx] = float2nr(ceil(str2float(idx) / (s:square_width + 1))) +endfor +let s:col_squarecol_map_white[8 + s:square_width * 8 + 1] = 1 + +let s:col_squarecol_map_black = {} +for idx in range(1, 8 + s:square_width * 8) + let s:col_squarecol_map_black[xoffset + idx] = 9 - float2nr(ceil(str2float(idx) / (s:square_width + 1))) +endfor +let s:col_squarecol_map_black[8 + s:square_width * 8 + 1] = 8 + + +let s:col_idx_to_letter = {1: 'a', 2: 'b', 3: 'c', 4: 'd', 5: 'e', 6: 'f', 7: 'g', 8: 'h'} + + +"""""""""""""""""""" +" gameplay functions +"""""""""""""""""""" +fun! lichess#play#write_msg(msg_txt) abort + if a:msg_txt == '' + return + endif + let msg = '' . a:msg_txt + call s:query_server(msg) +endfun + + +fun! lichess#play#abort_game() abort + let response = s:query_server("abort_game") + call s:check_for_query_error(response) + call lichess#util#log_msg('Game aborted. Response: ' . response, 0) +endfun + + +fun! lichess#play#resign_game() abort + let response = s:query_server("resign_game") + call s:check_for_query_error(response) + call lichess#util#log_msg('Game resigned. Response: ' . response, 0) +endfun + + +fun! lichess#play#claim_victory() abort + let response = s:query_server("claim_victory") + call s:check_for_query_error(response) + call lichess#util#log_msg('Tried to claim victory. Response: ' . response, 0) +endfun + + +fun! lichess#play#draw_offer(accept) abort + let response = s:query_server("" . a:accept) + call s:check_for_query_error(response) + call lichess#util#log_msg('Handle draw offer - accept=' . a:accept . ' - response: ' . response, 0) +endfun + + +fun! lichess#play#takeback_offer(accept) abort + let response = s:query_server("" . a:accept) + call s:check_for_query_error(response) + call lichess#util#log_msg('Handle takeback offer - accept=' . a:accept . ' - response: ' . response, 0) +endfun + + +fun! lichess#play#make_move(pos_end) abort + if !exists('b:lichess_pos_init') + return + endif + + let color = s:query_server('get_color') + + let pos_init_idx = s:cursor_pos_to_square_pos(b:lichess_pos_init, color) + if type(pos_init_idx) != 3 + return + endif + let pos_init = s:col_idx_to_letter[pos_init_idx[1]] . pos_init_idx[0] + unlet b:lichess_pos_init + + let pos_end_idx = s:cursor_pos_to_square_pos(a:pos_end, color) + if type(pos_end_idx) != 3 + return + endif + let pos_end = s:col_idx_to_letter[pos_end_idx[1]] . pos_end_idx[0] + + let move_uci = pos_init . pos_end + if !g:lichess_autoqueen && s:is_promotion(color, pos_init_idx, pos_end_idx) + call lichess#util#log_msg('function lichess#play#make_move: pawn is promoting!', 1) + let promotion_pieces = ['q', 'r', 'n', 'b'] + let new_piece = confirm("Choose promotion piece!", "&queen\n&rook\nk&night\n&bishop", 1) + let move_uci = pos_init . pos_end . promotion_pieces[new_piece - 1] + endif + + echo + call s:query_server('' . move_uci, 'lichess_move_info') +endfun + + +fun! lichess#play#make_move_keyboard() abort + let move_uci = input('Enter move (UCI notation): ') + echo + call s:query_server('' . move_uci, 'lichess_move_info') +endfun + + +fun! lichess#play#find_game() abort + " required api token + let g:lichess_api_token = get(g:, 'lichess_api_token', "") + if !len(g:lichess_api_token) + echohl ErrorMsg | echom "No API Token found! You need to add `let g:lichess_api_token = YOUR_API_TOKEN` to your vim config using your generated Token!" | + \ echom "You can easily create this API Token by logging in on lichess.org and then simply following the following link:" | + \ echom "https://lichess.org/account/oauth/token/create?scopes[]=challenge:write&scopes[]=board:play&description=vim+lichess" | + \ echohl None + return + endif + + let berserk_not_installed = stridx(system(g:python_cmd . ' -c "import berserk"'), 'ModuleNotFoundError') >= 0 + if berserk_not_installed + let choice = confirm("Berserk is not installed and needed for vim-lichess. Install it now?", "&yes\n&no", 1) + if choice == 1 + exe "!" . g:python_cmd . " -m pip install berserk" + else + echohl ErrorMsg | echom 'Berserk needs to be installed to use vim-lichess!' | echohl None + finish + endif + endif + + if g:lichess_debug_level != -1 + let plugin_path = lichess#util#plugin_path() + call writefile([g:lichess_debug_level], plugin_path . '/.debug_level') + endif + + if !(expand('%') == 'newgame.chess') + let shortmess_val = &shortmess + setlocal shortmess+=A + edit newgame.chess | setlocal buftype=nofile + exe 'setlocal shortmess=' . shortmess_val + endif + + let rated = g:lichess_rated ? "True" : "False" + let rating_range = len(g:lichess_rating_range) ? '[' . join(g:lichess_rating_range, ',') . ']' : "None" + let query = 'True/' . g:lichess_time . '-' . g:lichess_increment + \ . '-' . rated . '-' . g:lichess_variant . '-' . g:lichess_color . '-' . rating_range + if !exists('g:_lichess_server_started') + call s:start_game_loop() + sleep 500m + let response = s:query_server(query) + else + let response = s:query_server(query) + endif + call s:check_for_query_error(response) + + call lichess#setup_mappings() + syn clear lichess_searching_game + syn match lichess_searching_game /Searching for game.../ + call append(0, ["Searching for game..."]) +endfun + + +fun! lichess#play#update_board(...) abort + let all_info = s:query_server('get_all_info') + if s:check_for_query_error(all_info) + echohl ErrorMsg | echom "Error getting game info" | echohl None + return + endif + + if all_info == 'None' + return + endif + + let all_info = substitute(all_info, ": False", ": 0", "g") + let all_info = substitute(all_info, ": True", ": 1", "g") + let all_info = substitute(all_info, ": None", ': "None"', "g") + let all_info = json_decode(all_info) + + if all_info['last_err'] != "None" + echohl ErrorMsg | echom all_info['last_err'] | echohl None + endif + + let my_color = all_info['color'] + let searching_game = all_info['searching_game'] + + if my_color == 'None' + call lichess#util#log_msg('function lichess#play#update_board(): my_color is None', 1) + return + endif + + if searching_game != 0 " could also be 'None' + let searching_game = 1 + endif + + let player_info = all_info['player_info'] + let player_times = all_info['player_times'] + let username = all_info['username'] + let s:lichess_fen = split(all_info['fen'], ' ')[0] + let is_my_turn = all_info['is_my_turn'] + let status = all_info['status'] + let latest_move = all_info['latest_move'] + let messages = all_info['messages'] + let msg_sep = all_info['msg_sep'] + + let opp_color = my_color == 'white' ? 'black' : 'white' + if s:lichess_fen == 'None' + call lichess#util#log_msg('function lichess#play#update_board(): fen is None', 1) + return + endif + let curpos = getpos('.') + silent! exe '%delete_' + + if latest_move != 'None' && my_color != 'None' + let latest_move = s:get_row_col_move(latest_move, my_color) + endif + + call lichess#board_setup#display_board(s:lichess_fen, latest_move) " DISPLAY BOARD + + if player_info != "None" + let player_info = split(player_info, msg_sep) + + let my_rating = player_info[0] + let opp_rating = player_info[1] + let my_name = player_info[2] + let opp_name = player_info[3] + let my_title = player_info[4] == 'None' ? '' : player_info[4] . ' ' + let opp_title = player_info[5] == 'None' ? '' : player_info[5] . ' ' + + let td_since_last = str2float(split(player_times, '/')[0]) + let my_time = str2float(split(split(player_times, '/')[1], '-')[0]) + let opp_time = str2float(split(split(player_times, '/')[1], '-')[1]) + + if is_my_turn + let my_time = float2nr(my_time - td_since_last) + let opp_time = float2nr(opp_time) + else + let opp_time = float2nr(opp_time - td_since_last) + let my_time = float2nr(my_time) + endif + + if opp_time >= 0 && my_time >=0 && status == 'started' + let g:lichess_opp_time = printf("%02d", opp_time / 3600) . ':' . printf("%02d", (opp_time % 3600) / 60) . ':' . printf("%02d", opp_time % 3600 % 60) + let g:lichess_my_time = printf("%02d", my_time / 3600) . ':' . printf("%02d", (my_time % 3600) / 60) . ':' . printf("%02d", my_time % 3600 % 60) + elseif !exists('g:lichess_opp_time') || !exists('g:lichess_my_time') + let g:lichess_opp_time = '--:--:--' + let g:lichess_my_time = '--:--:--' + endif + + let opp_info = opp_title . opp_name . ' [' . opp_rating . '] - ' . g:lichess_opp_time + let my_info = my_title . my_name . ' [' . my_rating . '] - ' . g:lichess_my_time + syn clear lichess_user_turn + syn clear lichess_user_noturn + if is_my_turn + exe 'syn match lichess_user_turn /^' . my_title . my_name . ' .*' . '/ containedin=ALL' + exe 'syn match lichess_user_noturn /^' . opp_title . opp_name . ' .*' . '/ containedin=ALL' + else + exe 'syn match lichess_user_turn /^' . opp_title . opp_name . ' .*' . '/ containedin=ALL' + exe 'syn match lichess_user_noturn /^' . my_title . my_name . ' .*' . '/ containedin=ALL' + endif + + call append(0, [opp_info, '']) + call append('$', my_info) + + if status != 'started' + let pattern = '- ' . toupper(status) . ' -' + syn clear lichess_game_ended + exe 'syn match lichess_game_ended /' . pattern . '/ containedin=ALL' + call append(0, [pattern, '']) + endif + + if messages != 'None' + if !s:hl_was_set + syn match lichess_chat_bold /CHAT:/ containedin=ALL + syn match lichess_chat /CHAT:\_.*/ containedin=ALL + syn match lichess_chat_system /^> lichess:.*/ containedin=ALL + exe 'syn match lichess_chat_you /^> ' . username . ':.*/ containedin=ALL' + let s:hl_was_set = 1 + endif + let msg_lines = split(messages, msg_sep) + call map(msg_lines, {key, val -> '> ' . val}) + call append('$', ['', '', 'CHAT:'] + msg_lines) + endif + else + call append(0, ['', '']) + call append('$', '') + endif + + if searching_game && status != 'started' + syn clear lichess_searching_game + syn match lichess_searching_game /Searching for game.../ + call append(0, ["Searching for game...", "", ""]) + endif + + call cursor(curpos[1], curpos[2]) +endfun + + +"""""""""""""""""""""""" +" script local functions +"""""""""""""""""""""""" +fun! s:check_for_query_error(response, ...) abort + if a:0 > 0 + let hlgroup = a:1 + else + let hlgroup = -1 + endif + + let had_querry_error = a:response[:len('')-1] == '' + if had_querry_error && hlgroup != -1 + exe "echohl " . hlgroup . " | " . "echom substitute(a:response, '', '', '')" . " | echohl None" + return 1 + elseif had_querry_error + if stridx(a:response, 'is not in use anymore') < 0 + echom substitute(a:response, '', '', '') + endif + return 1 + endif + return 0 +endfun + + +fun! s:query_server(query, ...) abort + let plugin_path = lichess#util#plugin_path() +python3 << EOF +import os, sys, vim +plugin_path = vim.eval('plugin_path') +sys.path.append(os.path.join(plugin_path, 'python')) + +from util import log_message, query_server + +query = vim.eval('a:query') +port = int(vim.eval('g:_lichess_server_port')) + +log_message(f'function s:query_server(): query - {query}') +try: + response = query_server(query, port) + if isinstance(response, bytes): + response = response.decode('utf-8') + + if response is not None: + response = response.replace("'", '"') + vim.command(f"let response = '{response}'") +except Exception as e: + log_message(f"function s:query_server(): {str(e)}", 2) + vim.command(f"let response = '{str(e)}'") +EOF + + if a:0 > 0 + let hlgroup = a:1 + else + let hlgroup = -1 + endif + + call s:check_for_query_error(response, hlgroup) + return response +endfun + +function! s:get_row_col_move(move, color) abort + let from_letter = matchstr(a:move, '^[a-h]') + let from_number = matchstr(a:move, '^[a-h]\zs[1-8]') + let to_letter = matchstr(a:move, '[a-h][1-8]\zs[a-h]') + let to_number = matchstr(a:move, '[a-h][1-8][a-h]\zs[1-8]') + + if a:color == 'white' + let letter_to_col = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8} + let from_row = 9 - from_number + let to_row = 9 - to_number + let from_col = letter_to_col[from_letter] + let to_col = letter_to_col[to_letter] + else + let letter_to_col = {'a': 8, 'b': 7, 'c': 6, 'd': 5, 'e': 4, 'f': 3, 'g': 2, 'h': 1} + let from_row = from_number + let to_row = to_number + let from_col = letter_to_col[from_letter] + let to_col = letter_to_col[to_letter] + endif + return from_row . from_col . to_row . to_col +endfunction + + +fun! s:cursor_pos_to_square_pos(cursor_pos, color) abort + let lnum = a:cursor_pos[0] + let cnum = a:cursor_pos[1] + + if a:color == 'white' + if !has_key(s:line_squareline_map_white, lnum) || !has_key(s:col_squarecol_map_white, cnum) + echohl ErrorMsg | echom 'Invalid square' | echohl None + return -1 + endif + let square_row = s:line_squareline_map_white[lnum] + let square_col = s:col_squarecol_map_white[cnum] + else + if !has_key(s:line_squareline_map_black, lnum) || !has_key(s:col_squarecol_map_black, cnum) + echohl ErrorMsg | echom 'Invalid square' | echohl None + return -1 + endif + let square_row = s:line_squareline_map_black[lnum] + let square_col = s:col_squarecol_map_black[cnum] + endif + + return [square_row, square_col] +endfun + + +fun! s:is_promotion(color, pos_init_idx, pos_end_idx) abort + if !(a:color == 'black' && a:pos_end_idx[0] == 1 || a:color == 'white' && a:pos_end_idx[0] == 8) + return 0 + endif + + if s:lichess_fen == -1 + s:lichess_fen = s:query_server('get_fen') + if s:lichess_fen == 'None' + return 0 + endif + endif + + let plugin_path = lichess#util#plugin_path() +python3 << EOF +import os, sys, vim +plugin_path = vim.eval('plugin_path') +sys.path.append(os.path.join(plugin_path, 'python')) + +from util import fen_to_board +fen = vim.eval('s:lichess_fen') +color = vim.eval('a:color') +pos_init = [int(el) for el in vim.eval('a:pos_init_idx')] +board = fen_to_board(fen) +if color == 'white': + idx_row = 8 - pos_init[0] + idx_col = pos_init[1] - 1 +else: + idx_row = pos_init[0] - 1 + idx_col = 8 - pos_init[1] +is_pawn = int(board[idx_row][idx_col] in ['p', 'P']) +vim.command(f"let is_pawn = {is_pawn}") +EOF + + if !is_pawn + return 0 + endif + + return 1 +endfun + + +fun! s:kill_server() abort + call lichess#util#log_msg('function s:send_kill_signal: sending kill signal', 0) + call s:query_server('') + if exists('g:_lichess_server_started') + unlet g:_lichess_server_started + endif +endfun + + +fun! s:set_port() abort +python3 << EOF +import socket +from contextlib import closing + +def find_free_port(): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + +port = find_free_port() +vim.command(f"let g:_lichess_server_port = {port}") +EOF +endfun + + +fun! s:start_game_loop() abort + let g:_lichess_server_started = 1 + call s:set_port() + let cmd = g:python_cmd . ' ' . lichess#util#plugin_path() . '/python/play_game.py ' + \ . g:_lichess_server_port . ' ' . g:lichess_api_token + call lichess#util#log_msg('starting game loop', 0) + if has('nvim') + noautocmd exec "vsp|term " . cmd | let g:lichess_server_bufnr = bufnr() | setlocal nobl | hide + else + noautocmd exec "vsp|term ++curwin " . cmd | let g:lichess_server_bufnr = bufnr() | setlocal nobl | hide + endif + exe "autocmd QuitPre,BufDelete :silent! bd! " . g:lichess_server_bufnr + + let updatetime = 0.5 + let timer_id = timer_start(str2nr(string(1000 * updatetime)), + \ function('lichess#play#update_board'), {'repeat': -1}) + + exe "au BufLeave call timer_pause(" . timer_id . ", 1)" + exe "au BufEnter call timer_pause(" . timer_id . ", 0)" + exe "au BufDelete call timer_stop(" . timer_id . ")" + au BufDelete call s:kill_server() + let s:hl_was_set = 0 + call lichess#board_setup#syntax_matching() +endfun + + +fun! OnExit(job_id, code, event) dict + if a:code == 0 + exec 'bdelete ' . self.bufnr + endif +endfun diff --git a/autoload/lichess/util.vim b/autoload/lichess/util.vim new file mode 100644 index 0000000..b97ac97 --- /dev/null +++ b/autoload/lichess/util.vim @@ -0,0 +1,18 @@ +let s:script_path = expand(":p:h") + +fun! lichess#util#plugin_path() abort + let plugin_path = split(s:script_path, '/')[:-3] + return '/' . join(plugin_path, '/') +endfun + +fun! lichess#util#log_msg(msg, level) + let plugin_path = lichess#util#plugin_path() +python3 << EOF +import os, sys, vim +plugin_path = vim.eval('plugin_path') +sys.path.append(os.path.join(plugin_path, 'python')) + +from util import log_message +log_message(vim.eval('a:msg'), int(vim.eval('a:level'))) +EOF +endfun diff --git a/plugin/lichess.vim b/plugin/lichess.vim new file mode 100644 index 0000000..d36565c --- /dev/null +++ b/plugin/lichess.vim @@ -0,0 +1,65 @@ +fun! lichess#setup_mappings() abort + setlocal colorcolumn= + setlocal mouse=a + nnoremap :silent let b:lichess_pos_init = getpos(".")[1:2] + nnoremap :call lichess#play#make_move(getpos(".")[1:2]) + + if !hasmapto('LichessMakeMoveUCI', 'n') + nnoremap lm :LichessMakeMoveUCI + endif + if !hasmapto('LichessChat', 'n') + nnoremap lc :LichessChat + endif + if !hasmapto('LichessAbort', 'n') + nnoremap la :LichessAbort + endif + if !hasmapto('LichessResign', 'n') + nnoremap lr :LichessResign + endif + if !hasmapto('LichessOfferDraw', 'n') + nnoremap ldo :LichessOfferDraw + endif + if !hasmapto('LichessAcceptDraw', 'n') + nnoremap lda :LichessAcceptDraw + endif + if !hasmapto('LichessDeclineDraw', 'n') + nnoremap ldd :LichessDeclineDraw + endif +endfun + + +if !hasmapto('lichess#play#find_game()', 'n') + nnoremap ch :call lichess#play#find_game() +endif + + +command! -nargs=1 LichessChat :call lichess#play#write_msg() +command! LichessFindGame :call lichess#play#find_game() +command! LichessResign :call lichess#play#resign_game() +command! LichessAbort :call lichess#play#abort_game() +command! LichessClaimVictory :call lichess#play#claim_victory() +command! LichessDrawDecline :call lichess#play#draw_offer('no') +command! LichessDrawOfferAccept :call lichess#play#draw_offer('yes') +command! LichessTakebackOfferAccept :call lichess#play#takeback_offer('yes') +command! LichessTakebackOfferDecline :call lichess#play#takeback_offer('no') +command! LichessMakeMoveUCI :call lichess#play#make_move_keyboard() + + +" time (integer in minutes) - must be >= 8, since lichess API only allows rapid or classical games +let g:lichess_time = get(g:, 'lichess_time', 10) +" increment (integer in seconds) +let g:lichess_increment = get(g:, 'lichess_increment', 0) +" rated = False +let g:lichess_rated = get(g:, 'lichess_rated', 1) +" variant = "standard" +let g:lichess_variant = get(g:, 'lichess_variant', "standard") +" color = "random" +let g:lichess_color = get(g:, 'lichess_color', "random") +" rating_range = None (can be passed as [low,high]) +let g:lichess_rating_range = get(g:, 'lichess_rating_range', []) +" set debug level +let g:lichess_debug_level = get(g:, 'lichess_debug_level', -1) +" whether to automatically promote to queen or not +let g:lichess_autoqueen = get(g:, 'lichess_autoqueen', 1) +" command for python executable to run server in background (can also be full path) +let g:python_cmd = get(g:, 'python_cmd', 'python3') diff --git a/python/play_game.py b/python/play_game.py new file mode 100644 index 0000000..0dab71f --- /dev/null +++ b/python/play_game.py @@ -0,0 +1,234 @@ +import berserk +import time +import argparse +from datetime import datetime +from threading import Thread + +import util +from server import Server + + +def seek_game(client, port, *args, **kwargs): + try: + util.log_message( + f"function seek_game: seeking game with args: {args}, kwargs: {kwargs}" + ) + client.board.seek(*args, **kwargs) + except Exception as e: + util.log_message(f"function seek_game: {type(e)}: {str(e)}", 2) + if "HTTP 429" in str(e): + msg = "Too many HTTP requests at once, please wait one minute before trying again!" + util.query_server(f"{msg}", port) + else: + util.query_server(f"{str(e)}", port) + + +def game_loop(client, all_games, port): + util.query_server(f"False/None", port) + state = None + game = all_games[0] + color = game["color"] + + util.query_server(f"{color}", port) + util.query_server(f"{game['fen']}", port) + + game_id = game["gameId"] + util.query_server(f"{game_id}", port) + util.log_message(f"function game_loop: you're {color}! (game id: {game_id})") + fen, latest_move, last_move = None, None, None + + for state in client.board.stream_game_state(game_id): + last_game = game + game = util.get_current_game(client) + + util.log_message(f"state dict: {state} ---- game dict: {game}") + + if game is None: + game = last_game + + if state["type"] == "gameFull": + game_info = state + state = state["state"] + + my_info = game_info[color] + opp_info = game_info["white" if color == "black" else "black"] + util.query_server( + "" + + util.MSG_SEP.join( + [ + str(el) + for el in [ + my_info["rating"], + opp_info["rating"], + my_info["name"], + opp_info["name"], + my_info["title"], + opp_info["title"], + ] + ] + ), + port, + ) + elif state["type"] == "chatLine": + continue + + fen = game["fen"] + + curtime = datetime.now().strftime("%Y-%m-%d-%H:%M:%S.%f") + my_time = state["wtime" if color == "white" else "btime"] + opp_time = state["wtime" if color == "black" else "btime"] + if isinstance(my_time, int): + my_time_seconds = my_time / 1000 + opp_time_seconds = opp_time / 1000 + else: + my_time_seconds = my_time.second + my_time.minute * 60 + my_time.hour * 3600 + opp_time_seconds = ( + opp_time.second + opp_time.minute * 60 + opp_time.hour * 3600 + ) + + latest_move = game["lastMove"] + util.query_server( + "" + + util.MSG_SEP.join( + [ + f"{state['status']}", + f"{game['isMyTurn']}", + f"{fen}", + f"{curtime}/{my_time_seconds}-{opp_time_seconds}", + f"{latest_move}", + ] + ), + port, + ) + + util.log_message( + f"function game_loop: game loop over. last_move={last_move}, latest_move={latest_move}, fen={fen}, last state={state}" + ) + + latest_move = state["moves"].split(" ")[-1] if state is not None else None + if ( + not any([el is None for el in [fen, latest_move, state]]) + and len(latest_move) + and state["status"] == "mate" + ): + fen = util.change_fen_last_move(fen, latest_move) + util.log_message( + f"function game_loop: fen after change: {fen} (latest_move:{latest_move})" + ) + util.query_server(f"{fen}", port) + util.query_server(f"{latest_move}", port) + + +def wait_until_start_signal(port): + start_new_game = False + params = "" + while not start_new_game: + response = util.query_server(f"get_start_new_game", port) + if response is None: + time.sleep(1) + util.log_message( + "function wait_until_start_signal: server not responding ", 2 + ) + continue + + start_new_game, params = response.decode("utf-8").split("/") + start_new_game = start_new_game == "True" + + if not start_new_game: + time.sleep(1) + + # params in order: + # time (integer in minutes) + # increment (integer in seconds) + # rated = False + # variant = "standard" + # color = "random" + # rating_range = None (can be passed as [low,high]) + t, inc, rated, variant, color, rating_range = params.split("-") + t = int(t) + inc = int(inc) + rated = rated == "True" + if rating_range == "None": + rating_range = None + else: + l, h = rating_range.replace("[", "").replace("]", "").split(",") + rating_range = [int(l), int(h)] + + kwargs = { + "rated": rated, + "variant": variant, + "color": color, + "rating_range": rating_range, + } + + return (t, inc), kwargs + + +def start_server(port, token): + util.log_message("function start_server: starting lichess session") + session = berserk.TokenSession(token) + client = berserk.Client(session) + + try: + client.account.get() + except Exception as e: + util.log_message( + "function start_server: could not get account! " + f"possibly invalid api token ({str(e)})", + 3, + ) + raise e + + server = Server(port, client) + + if not util.is_port_in_use(port): + server.start() + else: + raise Exception("port already in use") + + while True: + all_games = client.games.get_ongoing() + seek_params = wait_until_start_signal(port) + if not len(all_games): + t2 = Thread( + target=seek_game, + args=(client, port, *seek_params[0]), + kwargs=seek_params[1], + ) + t2.start() + all_games = client.games.get_ongoing() + no_game = True + while no_game: + all_games = client.games.get_ongoing() + no_game = not len(all_games) + time.sleep(1) + elif len(all_games) > 1: + raise Exception("more than one game found!") + + util.log_message("function start_server: game found!") + game_loop(client, all_games, port) + util.log_message("function start_server: game ended!") + + +def parse_cmd_args(): + parser = argparse.ArgumentParser(description="vim-lichess server") + parser.add_argument("port", type=int, help="port to listen on") + parser.add_argument("token", type=str, help="lichess api token") + args = parser.parse_args() + return args + + +if __name__ == "__main__": + args = parse_cmd_args() + util.log_message(f"starting server on port: {args.port}", 0) + try: + start_server(args.port, args.token) + except Exception as e: + util.log_message(f"function start_server: {str(e)}", 3) + if "HTTP 429" in str(e): + msg = "Too many HTTP requests at once, please wait one minute before trying again!" + util.query_server(f"{msg}", args.port) + else: + util.query_server(f"{str(e)}", args.port) + + raise e diff --git a/python/server.py b/python/server.py new file mode 100644 index 0000000..13cd14d --- /dev/null +++ b/python/server.py @@ -0,0 +1,422 @@ +import socket +import json +import re +import berserk +import time +from datetime import datetime +from threading import Thread + +import util + +HOST = socket.gethostname() + + +def _parse_make_move_exception(e): + json_str = re.sub(r'HTTP 400.*?{', '{', str(e)).replace('\'', '"') + error = json.loads(json_str)["error"] + return f"{error}".encode() + + +class Server: + def __init__(self, port, client): + self.port = port + self.client = client + self.reset_parameters() + self.username = client.account.get()["username"] + util.log_message(f"Server started on port {self.port}") + self._update_dict = { + "chat": {"freq": 1, "last": time.time()}, + } + + def reset_parameters(self): + self.start_new_game = False + self.chat_messages = None + self.player_times = None + self.player_info = None + self.latest_move = None + self.game_params = None + self.last_game = None + self.next_move = None + self.last_err = None + self.premove = None + self.my_turn = None + self.game_id = None + self.status = None + self.color = None + self.fen = None + + @util.log_func_failure("couldn't start self.handle_client in new thread") + def start(self): + t = Thread(target=self.handle_client) + t.start() + + @util.log_func_failure("socket error occured") + def handle_client(self): + exit_ = False + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((HOST, self.port)) + while not exit_: + s.listen() + conn, _ = s.accept() + with conn: + while True: + data = conn.recv(1024) + if not data: + break + + data = data.decode("utf-8") + + if data == "": + util.log_message(f"function handle_client: received kill signal") + exit_ = True + break + + response = self.parse_data(data) + conn.sendall(response) + + self.post_response_update(data) + + util.log_message(f"function handle_client: socket closed") + + @util.log_func_failure("could not update attributes") + def post_response_update(self, data): + tdiff = time.time() - self._update_dict['chat']['last'] + if data == "get_all_info" and tdiff > self._update_dict['chat']['freq']: + self._update_dict['chat']['last'] = time.time() + if self.game_id is not None: + self._update_chat(self.client, self.game_id) + else: + self.chat_messages = None + + @util.log_func_failure("could not parse data") + def parse_data(self, data): + response = b"success" + + # new move + if data.startswith(""): + response = self.make_move(data) + elif data == "get_move": + response = f"{self.next_move}".encode() + # game_id + elif data.startswith(""): + self.game_id = data.replace("", "") + elif data == "get_game_id": + response = f"{self.game_id}".encode() + # is it my turn? + elif data.startswith(""): + self.my_turn = data.replace("", "") == "True" + elif data == "get_my_turn": + response = f"{self.my_turn}".encode() + # current fen + elif data.startswith(""): + response = self.set_fen(data) + elif data == "get_fen": + response = f"{self.fen}".encode() + # latest move + elif data.startswith(""): + response = self.set_latest_move(data) + elif data == "get_latest_move": + response = f"{self.latest_move}".encode() + # current game status + elif data.startswith(""): + self.status = data.replace("", "") + elif data == "get_status": + response = f"{self.status}".encode() + # what's my color + elif data.startswith(""): + self.color = data.replace("", "") + elif data == "get_color": + response = f"{self.color}".encode() + # player info: ----- + elif data.startswith(""): + self.player_info = data.replace("", "") + elif data == "get_player_info": + response = f"{self.player_info}".encode() + # current chat messages + elif data == "get_chat_messages": + response = f"{self.chat_messages}".encode() + # time left of each player + elif data.startswith(""): + self.player_times = data.replace("", "") + elif data == "get_player_times": + response = self.get_player_times() + # fen of last move in game which can't be obtained using game dict + elif data.startswith(""): + response = self.process_last_fen(data) + # write a chat message + elif data.startswith(""): + response = self.write_chat_message(data) + # signal that new game should be started + elif data.startswith(""): + response = self.handle_start_new_game(data) + elif data == "get_start_new_game": + response = f"{self.start_new_game}/{self.game_params}".encode() + # abort game + elif data == "abort_game": + response = self.abort_game() + # resign game + elif data == "resign_game": + response = self.resign_game() + # claim a victory if opponent left game + elif data == "claim_victory": + response = self.claim_victory() + # make/accept/decline draw offer + elif data.startswith(""): + response = self.draw_offer(data) + # make/accept/decline takeback offer + elif data.startswith(""): + response = self.takeback_offer(data) + # get current game dict + elif data == "get_game_dict": + response = self.get_game_dict() + # occured errors + elif data.startswith(""): + self.last_err = data.replace("", "") + # all needed info to display board and player info in vim + elif data.startswith(""): + response = self.parse_all_info(data) + elif data == "get_all_info": + response = self.get_all_info() + # unknown request + else: + response = b"unknown request" + util.log_message(f"function parse_data: unknown request: {data}", 2) + + util.log_message( + f"function parse_data: new request: {data} - RESPONSE: {response}" + ) + + return response + + @util.log_func_failure(_parse_make_move_exception) + def make_move(self, data): + move = data.replace("", "") + + try: + self.client.board.make_move(self.game_id, move=move) + except Exception as e: + if "not your turn" in str(e).lower() and not self.my_turn: + self.premove = move + return f"premoving {move}".encode() + else: + raise e + + return b"success" + + @util.log_func_failure("could not parse all info") + def parse_all_info(self, data): + data = data.replace("", "") + self.status, my_turn, fen, self.player_times, latest_move = data.split( + util.MSG_SEP + ) + self.my_turn = my_turn == "True" + self.latest_move = latest_move if len(latest_move) > 0 else None + + if self.premove is not None and my_turn: + self.make_move(f"{self.premove}") + self.premove = None + + return self.set_fen(fen) + + @util.log_func_failure("could not set fen") + def set_fen(self, data): + if self.color == 'white': + self.fen = data.replace("", "") + elif self.color == 'black': + self.fen = util.flip_fen(data.replace("", "")) + else: + return b"unknown color" + + return b"success" + + @util.log_func_failure("could not set latest move") + def set_latest_move(self, data): + move = data.replace("", "") + self.latest_move = move if len(move) > 0 else None + return b"success" + + @util.log_func_failure("could not process last fen") + def process_last_fen(self, data): + self.fen = data.replace("", "") + if self.color == "black": + self.fen = util.flip_fen(self.fen) + + return b"success" + + @util.log_func_failure("could not write chat message") + def write_chat_message(self, data): + msg = data.replace("", "") + if self.game_id is not None: + self.client.board.post_message(self.game_id, msg) + return b"success" + + return b"could not write text message" + + @util.log_func_failure("could not get game dict") + def get_game_dict(self): + game = util.get_current_game(self.client) + if game is not None: + if self.color == "black": + game["fen"] = util.flip_fen(game["fen"]) + + response = f"{game}".encode() + self.last_game = game + elif self.last_game is not None: + response = f"{self.last_game}".encode() + else: + response = b"could not get game dict - no game found" + + return response + + @util.log_func_failure("could not get player times") + def get_player_times(self): + if self.player_times is not None: + thentime = datetime.strptime( + self.player_times.split("/")[0], "%Y-%m-%d-%H:%M:%S.%f" + ) + td = (datetime.now() - thentime).total_seconds() + my_time = float(self.player_times.split("/")[1].split("-")[0]) + opp_time = float(self.player_times.split("/")[1].split("-")[1]) + return f"{td}/{my_time}-{opp_time}".encode() + + return f"{self.player_times}".encode() + + @util.log_func_failure("could not get all info") + def get_all_info(self): + if self.player_times is not None: + thentime = datetime.strptime( + self.player_times.split("/")[0], "%Y-%m-%d-%H:%M:%S.%f" + ) + td = (datetime.now() - thentime).total_seconds() + my_time = float(self.player_times.split("/")[1].split("-")[0]) + opp_time = float(self.player_times.split("/")[1].split("-")[1]) + times_parsed = f"{td}/{my_time}-{opp_time}" + else: + times_parsed = None + + all_info = { + "color": self.color, + "messages": self.chat_messages, + "player_info": self.player_info, + "player_times": times_parsed, + "is_my_turn": self.my_turn, + "latest_move": self.latest_move, + "status": self.status, + "username": self.username, + "fen": self.fen, + "msg_sep": util.MSG_SEP, + "last_err": self.last_err, + "searching_game": self.start_new_game, + } + + self.last_err = None + + return f"{all_info}".encode() + + @util.log_func_failure("could not resign game") + def resign_game(self): + if self.game_id is not None: + self.client.board.resign_game(self.game_id) + return b"success" + else: + util.log_message(f"function resign_game: no game id found!", 1) + return b"could not resign game - no game id found" + + @util.log_func_failure("could not abort game") + def abort_game(self): + if self.game_id is not None: + self.client.board.abort_game(self.game_id) + return b"success" + else: + util.log_message(f"function abort_game: no game id found!", 1) + return b"could not abort game - no game id found" + + @util.log_func_failure("could not set start new game signal") + def handle_start_new_game(self, data): + data = data.replace("", "") + # parameter order: + # time (integer in minutes) + # increment (integer in seconds) + # rated = False + # variant = "standard" + # color = "random" + # rating_range = None (can be passed as [low, high]) + start_new_game, game_params = data.split("/") + start_new_game = start_new_game == "True" + + if game_params != "None": + self.game_params = game_params + + self.start_new_game = start_new_game + + util.log_message( + "function handle_start_new_game: start_new_game: " + f"{start_new_game} (parsed: {self.start_new_game})," + f" params: {self.game_params}", + 0, + ) + + return b"success" + + @util.log_func_failure("could not claim victory") + def claim_victory(self): + path = f"/api/board/game/{self.game_id}/claim-victory" + response = self.client.board._r.post( + path, data=None, fmt=berserk.formats.TEXT, stream=False + ) + response = json.loads(response) + + if "ok" in response.keys(): + util.log_message("function claim_victory: ok", 0) + return b"success" + elif "error" in response.keys(): + util.log_message(f"function claim_victory error: {response['error']}", 2) + return f"{response['error']}".encode() + + @util.log_func_failure("could not handle draw offer") + def draw_offer(self, data): + accept = data.replace("", "") + path = f"/api/board/game/{self.game_id}/draw/{accept}" + response = self.client.board._r.post( + path, data=None, fmt=berserk.formats.TEXT, stream=False + ) + response = json.loads(response) + + if "ok" in response.keys(): + util.log_message("function draw_offer: ok", 0) + return b"success" + elif "error" in response.keys(): + util.log_message(f"function draw_offer error: {response['error']}", 2) + return f"{response['error']}".encode() + + @util.log_func_failure("could not handle takeback offer") + def takeback_offer(self, data): + accept = data.replace("", "") + path = f"/api/board/game/{self.game_id}/takeback/{accept}" + response = self.client.board._r.post( + path, data=None, fmt=berserk.formats.TEXT, stream=False + ) + response = json.loads(response) + + if "ok" in response.keys(): + util.log_message("function takeback_offer: ok", 0) + return b"success" + elif "error" in response.keys(): + util.log_message(f"function takeback_offer error: {response['error']}", 2) + return f"{response['error']}".encode() + + @util.log_func_failure("could not fetch game chat") + def _update_chat(self, client, game_id): + path = f"api/board/game/{game_id}/chat" + response = client.board._r.get( + path, data=None, fmt=berserk.formats.TEXT, stream=False + ) + response = json.loads(response) + chat = util.MSG_SEP.join([f'{msg["user"]}: {msg["text"]}' for msg in response]) + self.chat_messages = chat + + @util.log_func_failure("could not fetch game chat") + def _update_fen(self, client): + game = util.get_current_game(client) + if game is not None: + self.set_fen(game["fen"]) diff --git a/python/util.py b/python/util.py new file mode 100644 index 0000000..808c095 --- /dev/null +++ b/python/util.py @@ -0,0 +1,182 @@ +import socket +import time +import os +import re + +from datetime import datetime + + +PLUGIN_PATH = os.path.normpath(os.path.split(os.path.realpath(__file__))[0] + os.sep + os.pardir) +HOST = socket.gethostname() +MSG_SEP = "-,-/ß/-,-" + + +_debug_file = os.path.join(PLUGIN_PATH, ".debug_level") +if not os.path.isfile(_debug_file): + DEBUG_LEVEL = -1 +else: + try: + with open(_debug_file, "r") as f: + DEBUG_LEVEL = int(f.read()) + assert DEBUG_LEVEL in [0, 1, 2, 3], "debug level must be 0, 1, 2 or 3" + except Exception as e: + DEBUG_LEVEL = -1 + + +def log_message(message, level=0): + if level < DEBUG_LEVEL or DEBUG_LEVEL == -1: + return + elif level == 0: + prefix = "[INFO]" + elif level == 1: + prefix = "[WARNING]" + elif level == 2: + prefix = "[ERROR]" + elif level == 3: + prefix = "[CRASH]" + else: + raise ValueError("level must be 0, 1, 2 or 3") + + date = datetime.now().strftime("%Y_%m_%d") + current_time = datetime.now().strftime("%H:%M:%S.%f") + + log_dir = os.path.join(PLUGIN_PATH, "log") + if not os.path.isdir(log_dir): + os.mkdir(log_dir) + + with open(os.path.join(log_dir, date + ".log"), "a+") as f: + f.write(f"{prefix} {current_time}: {message}\n") + + +def log_func_failure(handle_failure): + """ decorator which logs occuring errors using the `log_message` function + with the function name, the given error_message and the error string """ + + def log_decorator(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + if isinstance(handle_failure, str): + log_message(f"{func.__name__}: {handle_failure}: {str(e)}", 2) + return f"{handle_failure}".encode() + elif callable(handle_failure): + return handle_failure(e) + else: + raise TypeError('Unexpected argument type for `handle_failure`') + + return wrapper + + return log_decorator + + +def is_port_in_use(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((HOST, port)) == 0 + + +def query_server(query, port, max_tries=5): + for _ in range(max_tries): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((HOST, port)) + s.sendall(query.encode()) + response = s.recv(1024) + + return response + except ConnectionRefusedError as e: + log_message(f"connection refused (query: {query}): {str(e)}", 2) + time.sleep(0.03) + + if not is_port_in_use(port): + raise ConnectionRefusedError(f"port {port} is not in use anymore") + + +def flip_fen(fen): + fen_split = fen.split(" ") + fen_pos, fen_rest = fen_split[0], fen_split[1:] + pos_flipped = "/".join([el[::-1] for el in fen_pos.split("/")[::-1]]) + return f"{pos_flipped} {' '.join(fen_rest)}" + + +def get_current_game(client): + all_games = client.games.get_ongoing() + if not len(all_games) == 1: + return None + + return all_games[0] + + +def fen_to_board(fen): + rows = fen.split(" ")[0].split("/") + num = [str(i) for i in range(1, 9)] + board = [ + list("".join([("0" * int(p) if p in num else p) for p in row])) for row in rows + ] + + return board + + +def board_to_fen(board): + new_fen = [] + for row in board: + num_before = False + new_row = "" + n = 0 + for el in row: + if el == "0": + n += 1 + num_before = True + elif num_before: + new_row += str(n) + el + n = 0 + num_before = False + else: + new_row += el + + if num_before: + new_row += str(n) + + new_fen.append(new_row) + + return "/".join(new_fen) + + +def change_fen_last_move(fen, last_move): + fen_split = fen.split(" ") + fen_pos, fen_rest = fen_split[0], fen_split[1:] + color = fen_rest[0][0] + + m_row = re.match(r"[a-z](\d)[a-z](\d)[a-z]*", last_move) + m_idx = re.match(r"([a-z])\d([a-z])\d([a-z]*)", last_move) + + assert ( + m_row is not None and m_idx is not None + ), f"last move could not be parsed! (last_move: {last_move} - type:{type(last_move)})" + + letter_idx_map = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4, "f": 5, "g": 6, "h": 7} + + row_before_i, row_after_i = m_row.groups() + row_before_i, row_after_i = 8 - int(row_before_i), 8 - int(row_after_i) + idx_before_i, idx_after_i, new_piece = m_idx.groups() + idx_before, idx_after = letter_idx_map[idx_before_i], letter_idx_map[idx_after_i] + + board = fen_to_board(fen) + + if len(new_piece): + moved_piece = new_piece.upper() if color == "w" else new_piece.lower() + else: + moved_piece = board[row_before_i][idx_before] + + board[row_before_i][idx_before] = "0" + board[row_after_i][idx_after] = moved_piece + + if last_move == "e1g1" or last_move == "e8g8": + board[row_before_i][-1] = "0" + board[row_before_i][5] = "R" if color == "w" else "r" + elif last_move == "e1c1" or last_move == "e8c8": + board[row_before_i][0] = "0" + board[row_before_i][3] = "R" if color == "w" else "r" + + fen_pos = board_to_fen(board) + return f"{fen_pos} {' '.join(fen_rest)}"