Skip to content

Commit

Permalink
Python Starter Bot (#5)
Browse files Browse the repository at this point in the history
* Python bot init talks with server

* Python talk with server up to started match

* Python starter bot client done

* - Fixed socket read buffer
- Named tuple XY and BotInfo

* - Revert back to dataclasses (faster)

* - Socket - skip server round data to stay in sync

* - README
- bot comments
- optimized Socket performance
  • Loading branch information
Rush-iam authored Jul 2, 2022
1 parent d22da43 commit 5444357
Show file tree
Hide file tree
Showing 14 changed files with 340 additions and 2 deletions.
4 changes: 2 additions & 2 deletions build.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
##Структура проекта
## Структура проекта

- `common` - модуль с общими классами, используемыми и на стороне сервера, и на стороне бота
- `server` - модуль с кодом сервера. Дорабатывается исключительно администраторами.
- `starter-bot` - модуль-заготовка для бота. Он же - простейший пример. Можно брать за основу для работы над ботом.

##Сборка и запуск проекта
## Сборка и запуск проекта

Сборка проекта осуществляется с помощью [Maven](https://ru.wikipedia.org/wiki/Apache_Maven).
Шаги сборки:
Expand Down
12 changes: 12 additions & 0 deletions starter-bot-python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## 🐍 Python Starter bot

### Как использовать

[`main.py`](main.py) - настройки подключения к серверу

[`bot.py`](bot.py) - код бота и его параметры

Требования: **Python 3.10+**

---
_[Артем **nGragas** Корников](https://t.me/Rush_iam) для [Croc Bot Battle](https://brainz.croc.ru/hello-work)_
38 changes: 38 additions & 0 deletions starter-bot-python/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from client.bot_base import BotBase
from client.message.extra_types import Mode
from client.message.messages import MatchStarted, Update

# Параметры регистрации бота на сервере
bot_name = 'SuperStarter'
bot_secret = ''
mode = Mode.FRIENDLY


class Bot(BotBase):
# Старт
def on_match_start(self, match_info: MatchStarted):
# Правила матча
self.match_info = match_info
self.id = match_info.your_id
print(match_info)
print(f'Матч стартовал! Бот <{self.name}> готов')

# Каждый ход
def on_update(self, update: Update) -> tuple[int, int]:
# Данные раунда: что бот "видит"
round_number = update.round
coins = update.coin
blocks = update.block
my_bot = next((bot for bot in update.bot if bot.id == self.id), None)
opponents = [bot for bot in update.bot if bot.id != self.id]

# Выбираем направление движения
import random
dx = random.choice([-1, 0, 1])
dy = random.choice([-1, 0, 1])

return dx, dy # Отправляем ход серверу

# Конец матча
def on_match_over(self) -> None:
print('Матч окончен')
Empty file.
26 changes: 26 additions & 0 deletions starter-bot-python/client/bot_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from dataclasses import dataclass, field
from typing import TypeVar

from client.message.extra_types import Mode
from client.message.messages import MatchStarted, Update


BotImpl = TypeVar('BotImpl', bound='BotBase')


@dataclass
class BotBase:
name: str
secret: str = None
mode: Mode = Mode.FRIENDLY
match_info: MatchStarted = field(init=False)
id: int = field(init=False)

def on_match_start(self, match_info: MatchStarted):
raise NotImplementedError

def on_update(self, update: Update) -> tuple[int, int]:
raise NotImplementedError

def on_match_over(self) -> None:
raise NotImplementedError
21 changes: 21 additions & 0 deletions starter-bot-python/client/bot_match_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .bot_base import BotImpl
from .client import HypernullClient
from .message import messages


class BotMatchRunner:
def __init__(self, bot: BotImpl, client: HypernullClient):
self.bot = bot
self.client = client

def run(self) -> None:
self.client.register(self.bot)

match_info: messages.MatchStarted = self.client.get()
self.bot.on_match_start(match_info)

while update := self.client.get_update():
dx, dy = self.bot.on_update(update)
self.client.move(dx, dy)

self.bot.on_match_over()
48 changes: 48 additions & 0 deletions starter-bot-python/client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from .bot_base import BotImpl
from .socket_session import SocketSession
from .message import factory, messages, extra_types


class HypernullClient:
version: int = 1

def __init__(self, host: str = 'localhost', port: int = 2021):
self.session = SocketSession(host, port)
msg = self.get()
if not isinstance(msg, messages.Hello):
raise Exception(
f'Wanted message {messages.Hello.__name__}, got: {type(msg)}'
)

if msg.protocol_version != self.version:
raise Exception(
f'Client v{self.version}, but Server v{msg.protocol_version}'
)

def get(self) -> factory.Message:
data = self.session.read()
return factory.MessageFactory.load(data)

def send(self, msg: messages.MessageBase) -> None:
data = msg.dump()
self.session.write(data)

def register(self, bot: BotImpl) -> None:
register = messages.Register(
bot_name=bot.name,
bot_secret=bot.secret,
mode=bot.mode,
)
self.send(register)

def get_update(self) -> messages.Update | None:
update: messages.Update | messages.MatchOver = self.get()
if isinstance(update, messages.MatchOver):
return None
return update

def move(self, dx: int, dy: int) -> None:
move = messages.Move(
offset=extra_types.XY(dx, dy)
)
self.send(move)
Empty file.
18 changes: 18 additions & 0 deletions starter-bot-python/client/message/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from dataclasses import dataclass, asdict


@dataclass
class MessageBase:
@classmethod
def type(cls) -> str:
return cls.__name__.lower()

def dump(self) -> str:
command = self.type()
params = '\n'.join(
f'{k} {" ".join(map(str, v.values())) if isinstance(v, dict) else v}'
for k, v in asdict(self).items() if v not in [None, '']
)
return f'{command}\n' \
f'{params}\n' \
f'end\n'
19 changes: 19 additions & 0 deletions starter-bot-python/client/message/extra_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dataclasses import dataclass
from enum import Enum


class Mode(str, Enum):
FRIENDLY = 'FRIENDLY'
DEATHMATCH = 'DEATHMATCH'


@dataclass
class XY:
x: int
y: int


@dataclass
class BotInfo(XY):
coins: int
id: int
60 changes: 60 additions & 0 deletions starter-bot-python/client/message/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import dataclasses
import inspect
import types
from collections import defaultdict
from typing import TypeVar, Type

from . import messages


Message = TypeVar('Message', bound=messages.MessageBase)


class MessageFactory:
_known_message_types: dict[str, Type[Message]] = dict(
inspect.getmembers(messages, inspect.isclass)
)

@classmethod
def load(cls, data: list[str]) -> Message:
if not data:
raise Exception('got empty data')

command = cls._to_camel_case(data[0])
if command not in cls._known_message_types:
raise Exception(f'unknown command: {command}')

message_class = cls._known_message_types[command]
field_type_mapping: dict[str, tuple[type | None, type]] = {
field.name: cls._get_field_types(field)
for field in dataclasses.fields(message_class)
}

params = defaultdict(list)
for row in data[1:-1]:
name, *value = row.split()

container, real_type = field_type_mapping[name]
if container is None:
params[name] = real_type(*value)
elif container is list:
# map(int, ) assumes that all nested types has only int fields
params[name].append(real_type(*map(int, value)))
else:
raise Exception(f'cannot handle {command}:{name}:{container}')

return message_class(**params)

@staticmethod
def _get_field_types(field: dataclasses.Field) -> tuple[type | None, type]:
if isinstance(field.type, types.GenericAlias):
container = field.type.__origin__
real_type = field.type.__args__[0]
else:
container = None
real_type = field.type
return container, real_type

@staticmethod
def _to_camel_case(snake_case: str) -> str:
return ''.join(t.title() for t in snake_case.split('_'))
48 changes: 48 additions & 0 deletions starter-bot-python/client/message/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from dataclasses import dataclass, field

from . import extra_types
from .base import MessageBase


@dataclass
class Hello(MessageBase):
protocol_version: int


@dataclass
class Register(MessageBase):
bot_name: str
bot_secret: str
mode: extra_types.Mode


@dataclass
class MatchStarted(MessageBase):
num_rounds: int
mode: extra_types.Mode
map_size: extra_types.XY
your_id: int
view_radius: int
mining_radius: int
attack_radius: int
move_time_limit: int
match_id: int = 0
num_bots: int = 0


@dataclass
class Update(MessageBase):
round: int
bot: list[extra_types.BotInfo] = field(default_factory=list)
block: list[extra_types.XY] = field(default_factory=list)
coin: list[extra_types.XY] = field(default_factory=list)


@dataclass
class Move(MessageBase):
offset: extra_types.XY


@dataclass
class MatchOver(MessageBase):
pass
36 changes: 36 additions & 0 deletions starter-bot-python/client/socket_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import logging
import socket


class SocketSession:
_buffer_size = 8192

def __init__(self, host: str, port: int):
self.socket = socket.socket()
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.connect((host, port))
self.buffer = bytearray()

def __del__(self):
self.socket.close()

def read(self) -> list[str]:
end_index = self._find_end_index()
while end_index == -1:
self.buffer += self.socket.recv(self._buffer_size)
end_index = self._find_end_index()

data = self.buffer[:end_index + 3]
self.buffer = self.buffer[end_index + 4:]

if len(self.buffer) > 0:
logging.warning('skipping round, seems like your bot had timed out')
return self.read()

return data.decode().split('\n')

def _find_end_index(self):
return self.buffer.find(b'end\n', len(self.buffer) - self._buffer_size)

def write(self, data: str) -> None:
self.socket.sendall(data.encode())
12 changes: 12 additions & 0 deletions starter-bot-python/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from bot import Bot, bot_name, bot_secret, mode
from client.bot_match_runner import BotMatchRunner
from client.client import HypernullClient

server_host = 'localhost'
server_port = 2021

if __name__ == '__main__':
BotMatchRunner(
bot=Bot(bot_name, bot_secret, mode),
client=HypernullClient(server_host, server_port),
).run()

0 comments on commit 5444357

Please sign in to comment.