diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22fae77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +token.txt +*.sgf +*.png diff --git a/README.md b/README.md index d1005a5..6d8b135 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ A discord bot for playing rengo games! -WIP! +# Dependencies +- sgf-render +- python-discord +- python-sgfmill + +Make sure to run the bot in an environment with read/write permissions diff --git a/__pycache__/sgfengine.cpython-39.pyc b/__pycache__/sgfengine.cpython-39.pyc new file mode 100644 index 0000000..201d8df Binary files /dev/null and b/__pycache__/sgfengine.cpython-39.pyc differ diff --git a/rengobot.py b/rengobot.py new file mode 100644 index 0000000..d1a6129 --- /dev/null +++ b/rengobot.py @@ -0,0 +1,283 @@ +import os +import ast +import time +from datetime import datetime, timedelta +import asyncio + +import sgfengine + +import discord +from discord.ext import commands + +# We don't use fancy slash commands here. It seems there is this library for python but it looks a bit more involved. +# https://pypi.org/project/discord-py-slash-command/ +bot = commands.Bot(command_prefix='$', help_command=None) + +# People who can start and resign games :O +# Later we might replace this with checking for a role. +admins=[ 756220448118669463, # Young Sun + 732403731810877450, # Yeonwoo + 145294584077877249, # Mrchance + 477895596141707264 # René + ] + +with open("token.txt") as f: + token = f.readlines()[0] # Get your own token and put it in token.txt +f.close() + +#client= discord.Client() + +#@bot.command() +#async def test(ctx, arg1, arg2): +# await ctx.send('You passed {} and {}'.format(arg1, arg2)) + +@bot.command() +async def help(ctx): + print(ctx.message.content) + await ctx.send( + '$help : shows this help\n\n'+ + '$join : join the game in this channel\n'+ + '$leave: leave the game in this channel\n'+ + '$play : play a move. For example, `$play Q16`\n\n'+ + '$sgf: get the sgf file of the game\n'+ + '$queue: get the queue of players\n\n'+ + '$newgame : starts a game in this channel (admin only!)\n'+ + '$resign : resigns the game in this channel (admin only!)' + ) + # ctx has guild, message, author, send, and channel (?) + +@bot.command() +async def play(ctx, arg): + channel_id= ctx.channel.id + user = ctx.author + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + filter_state= [i for i in range(len(state)) if state[i][0] == channel_id] + if not filter_state: + await ctx.send("No active game in this channel!") + return + + i= filter_state[0] + + if state[i][1] == queue and user.id not in state[i][4]: + await ctx.send("Player hasn't joined yet! Join us with `$join`") + return + + if state[i][1] == queue and user.id!= state[i][4][0]: + await ctx.send("It is not your turn yet!") + return + + legal_moves=[chr(col+ord('A')-1)+str(row) for col in range(1,21) if col!=9 for row in range(1,20)] + if arg not in legal_moves: + await ctx.send("I don't understand the move! Please input it in the format `$play Q16`") + return + + # TODO test for last player to move and time in free games + # perhaps we could switch it to max one move per player every 5 turns, put the last few players in state[i][4] + + try: + sgfengine.play_move(str(channel_id), arg, user.name) + except ValueError as e: + await ctx.send(str(e)) + return + + state[i][4].pop(0) + state[i][4].append(user.id) + + file = discord.File(str(ctx.channel.id)+".png") + if state[i][1]=="queue": + next_player=(await bot.fetch_user(state[i][4][0])) + await ctx.send(file=file, content="{}'s turn! ⭐".format(next_player.mention)) + else: + await ctx.send(file=file) + + with open("state.txt", "w") as f: f.write(repr(state)) + f.close() + +@bot.command() +async def join(ctx): + channel_id= ctx.channel.id + user = ctx.author + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + filter_state= [i for i in range(len(state)) if state[i][0] == channel_id] + if not filter_state: + await ctx.send("No active game in this channel!") + return + + i= filter_state[0] + + if user.id in state[i][4]: + await ctx.send("Player already in this game!") + return + + if state[i][1] != "queue": + await ctx.send("This game has no queue! Play whenever you want :P") + return + + state[i][4].append(user.id) + + await ctx.send("User joined!") + + with open("state.txt", "w") as f: f.write(repr(state)) + f.close() + +@bot.command() +async def queue(ctx): + channel_id= ctx.channel.id + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + filter_state= [i for i in range(len(state)) if state[i][0] == channel_id] + if not filter_state: + await ctx.send("No active game in this channel!") + return + + i= filter_state[0] + + if state[i][1] != "queue": + await ctx.send("This game has no queue! Play whenever you want :P") + return + + output= "Player list:\n" + for i, player_id in enumerate(state[i][4]): + player_name=(await bot.fetch_user(player_id)).name + output+=str(i+1)+". "+ player_name+"\n" + + if not state[i][4]: + output+="Nobody yet! Join us with `$join`" + + await ctx.send(output) + +@bot.command() +async def sgf(ctx): + file = discord.File(str(ctx.channel.id)+".sgf") + await ctx.send(file=file) + +@bot.command() +async def leave(ctx): + channel_id= ctx.channel.id + user = ctx.author + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + filter_state= [i for i in range(len(state)) if state[i][0] == channel_id] + if not filter_state: + await ctx.send("No active game in this channel!") + return + + i= filter_state[0] + + if user.id not in state[i][4]: + await ctx.send("Player not in this game!") + return + + if state[i][1] != "queue": + await ctx.send("This game has no queue! No need to leave!") + return + + state[i][4].remove(user.id) + + await ctx.send("User left :(") + + with open("state.txt", "w") as f: f.write(repr(state)) + f.close() + +@bot.command() +async def newgame(ctx, arg): + channel_id= ctx.channel.id + user = ctx.author + + if user.id not in admins: + await ctx.send("You don't have permissions for this!") + return + + if arg not in ["queue", "random"]: + await ctx.send("Unrecognized game type! Please try `$newgame ") + return + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + if ctx.channel.id in [ ch for (ch,_,_,_,_) in state]: + await ctx.send("A game is already active in this channel!") + return + + sgfengine.new_game(str(ctx.channel.id)) + state.append((ctx.channel.id, arg, None, None, [])) + + file = discord.File(str(ctx.channel.id)+".png") + if arg=="queue": + await ctx.send(file=file, content="A new game has started! Join with `$join`") + else: + await ctx.send(file=file, content="A new game has started! Play with `$play `") + + with open("state.txt", "w") as f: f.write(repr(state)) + f.close() + +@bot.command() +async def resign(ctx, arg): + channel_id= ctx.channel.id + user = ctx.author + + if user.id not in admins: + await ctx.send("You don't have permissions for this!") + return + + if arg not in ["W","B"]: + await ctx.send("Unrecognized colour! Please try `$resign ` to resign as Black/White") + return + + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + now=datetime.now() + file_name= "rengo_"+now.strftime("%Y_%m_%d_%H_%M_%S_")+ctx.channel.name+".sgf" #remove the hour minute and second later + + sgfengine.resign(str(channel_id), arg, file_name) + + file = discord.File(file_name) + await ctx.send(file=file, content=("Black" if arg=="W" else "White")+" wins!") + + state = [s for s in state if s[0]!=channel_id] + + with open("state.txt", "w") as f: f.write(repr(state)) + f.close() + +async def background_task(): + await bot.wait_until_ready() + print("bot ready!") + + guild=discord.utils.get(bot.guilds, name="Awesome Baduk") + + while not bot.is_closed(): + try: + print("ping") + await asyncio.sleep(1000) + + # lowest effort serialization + with open("state.txt") as f: state = ast.literal_eval(f.read()) + f.close() + + #TODO find who has to move, skip players accordingly, notify if any has to move + + with open("state.txt") as f: f.write(repr(state)) + f.close() + + except ConnectionResetError: + print("Connection error") + +bot.loop.create_task(background_task()) +bot.run(token) + diff --git a/sgfengine.py b/sgfengine.py new file mode 100644 index 0000000..11a4165 --- /dev/null +++ b/sgfengine.py @@ -0,0 +1,78 @@ +# Needs sgf-render and sgfmill +# https://mjw.woodcraft.me.uk/sgfmill/doc/1.1.1/properties.html?highlight=list%20properties +import os +from sgfmill import sgf, boards, sgf_moves, ascii_boards + +# This file only deals with the png and sgf side of things. To manage users etc go to the main file. + +def new_game(channel_id): + game= sgf.Sgf_game(19) + + with open (channel_id+".sgf", "wb") as f: + f.write(game.serialise()) + f.close() + + os.system("sgf-render --style fancy -o "+channel_id+".png -n last "+channel_id+".sgf") + +# Could be an illegal move, or maybe I don't understand the message +# outputs to .png +def play_move(channel_id, messagestr, player): + + thecol= ord(messagestr[0].lower()) - ord('a') + if thecol>8: thecol-=1 # Go boards don't have an I column!! + therow= int(messagestr[1:]) - 1 + + with open(channel_id+".sgf","rb") as f: + game = sgf.Sgf_game.from_bytes(f.read()) + f.close() + + koban=None + node= game.get_last_node() + board, moves= sgf_moves.get_setup_and_moves(game) + for (colour, (row, col)) in moves: + koban=board.play(row,col,colour) + + if (therow, thecol)==koban: + raise ValueError("Ko banned move!") + + colour = "w" if ("B" in node.properties()) else "b" + + board2= board.copy() + try: + board2.play(therow, thecol, colour) + except ValueError as e: + raise ValueError("Illegal move1!") + + if ascii_boards.render_board(board)== ascii_boards.render_board(board2): + raise ValueError("Illegal move2!") + + #print(moves) + + node2= node.new_child() + node2.set(("B" if colour =='b' else "W"), (therow,thecol)) + if koban is not None: node2.set("SQ", [koban]) + node2.set("CR", [(therow, thecol)]) + node2.set("C", player) # I think this would be fun for the review + if node.has_property("CR"): node.unset("CR") + if node.has_property("SQ"): node.unset("SQ") + + with open (channel_id+".sgf", "wb") as f: + f.write(game.serialise()) + f.close() + + os.system("sgf-render --style fancy -o "+channel_id+".png -n last "+channel_id+".sgf") + +# colour is "B" if black resigns, "W" if white resigns +def resign(channel_id, colour, file_name): + with open(channel_id+".sgf","rb") as f: + game = sgf.Sgf_game.from_bytes(f.read()) + f.close() + + node= game.root + node.set("RE", ("B" if colour=="W" else "W")+"+R") + + with open (file_name, "wb") as f: + f.write(game.serialise()) + f.close() + + os.remove(channel_id+".sgf") diff --git a/state.txt b/state.txt new file mode 100644 index 0000000..1c59b50 --- /dev/null +++ b/state.txt @@ -0,0 +1 @@ +[(881537306073387091, 'queue', None, None, [477895596141707264])] \ No newline at end of file