Switch to FastAPI

This commit is contained in:
Ben Shiller 2023-09-09 20:45:24 -05:00
parent 3654aebb5e
commit 72ea0126f7
No known key found for this signature in database
GPG Key ID: DC46F01400846797
9 changed files with 275 additions and 102 deletions

View File

@ -1,16 +1,14 @@
FROM python:3.11.4-slim
# FROM continuumio/conda-ci-linux-64-python3.8
# FROM continuumio/miniconda3
# Install app
COPY . /app
WORKDIR /app
COPY . .
# Install dependencies
RUN pip install --upgrade pip && pip install -r requirements.txt
# RUN conda env create -f environment.yaml
# ENV CONDA_DEFAULT_ENV battlesnake
ENV HOST 0.0.0.0
ENV PORT 8000
ENV ROOT_PATH /
ENV SNAKE starter_snake
# Run Battlesnake
ENTRYPOINT [ "python", "server.py" ]
CMD [ "starter_snake" ]
ENTRYPOINT uvicorn src.server:app --host $HOST --port $PORT --root-path $ROOT_PATH

View File

@ -1,10 +1,33 @@
annotated-types==0.5.0
anyio==3.7.1
blinker==1.6.2
click==8.1.7
fastapi==0.103.1
Flask==2.3.3
h11==0.14.0
h2==4.1.0
hpack==4.0.0
httptools==0.6.0
hyperframe==6.0.1
idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
mypy==1.5.1
mypy-extensions==1.0.0
marshmallow==3.20.1
packaging==23.1
priority==2.0.0
pydantic==2.3.0
pydantic-extra-types==2.1.0
pydantic_core==2.6.3
python-dotenv==1.0.0
PyYAML==6.0.1
sniffio==1.3.0
starlette==0.27.0
typing-inspect==0.9.0
typing_extensions==4.7.1
uvicorn==0.23.2
uvloop==0.17.0
watchfiles==0.20.0
websockets==11.0.3
Werkzeug==2.3.7
wsproto==1.2.0

View File

@ -1,63 +0,0 @@
import argparse
import logging
import os
import typing
from flask import Flask
from flask import request
from snakes.snake import Snake
def run_server(snake: Snake, host: str, port: int):
path = snake.name
app = Flask("Battlesnake")
@app.get(f"/{path}/")
def on_info():
return snake.info()
@app.post(f"/{path}/start")
def on_start():
game_state = request.get_json()
snake.start(game_state)
return "ok"
@app.post(f"/{path}/move")
def on_move():
game_state = request.get_json()
return snake.move(game_state)
@app.post(f"/{path}/end")
def on_end():
game_state = request.get_json()
snake.end(game_state)
return "ok"
@app.after_request
def identify_server(response):
response.headers.set(
"server", "shillerben/gitea/starter-snake-python"
)
return response
logging.getLogger("werkzeug").setLevel(logging.ERROR)
print(f"\nRunning Battlesnake at http://{host}:{port}/{path}")
app.run(host=host, port=port)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-b", "--bind", default="0.0.0.0", help="host ip to listen on")
parser.add_argument("-p", "--port", type=int, default=8000, help="host port to listen on")
parser.add_argument("snake", help="snake to run")
args = parser.parse_args()
host = args.bind
port = args.port
snake_name = args.snake
snake = Snake.SNAKES[snake_name]()
run_server(snake, host, port)

0
src/__init__.py Normal file
View File

166
src/api.py Normal file
View File

@ -0,0 +1,166 @@
from typing import Union, List
from enum import StrEnum, auto
from pydantic import BaseModel, PositiveInt, NonNegativeInt, conint, constr
'''
Enums
'''
class GameMode(StrEnum):
EMPTY_STR = ""
STANDARD = auto()
SOLO = auto()
ROYALE = auto()
SQUAD = auto()
CONSTRICTOR = auto()
WRAPPED = auto()
class Source(StrEnum):
TOURNAMENT = auto()
LEAGUE = auto()
ARENA = auto()
CHALLENGE = auto()
CUSTOM = auto()
class GameMap(StrEnum):
EMPTY_STR = ""
STANDARD = auto()
EMPTY = auto()
ARCADE_MAZE = auto()
ROYALE = auto()
SOLO_MAZE = auto()
HZ_INNER_WALL = auto()
HZ_RINGS = auto()
HZ_COLUMNS = auto()
HZ_RIVERS_BRIDGES = auto()
HZ_SPIRAL = auto()
HZ_SCATTER = auto()
HZ_GROW_BOX = auto()
HZ_EXPAND_BOX = auto()
HZ_EXPAND_SCATTER = auto()
class GameMove(StrEnum):
UP = auto()
DOWN = auto()
LEFT = auto()
RIGHT = auto()
'''
Models
'''
class RoyaleRulesetSettings(BaseModel):
shrinkEveryNTurns: NonNegativeInt
class SquadRulesetSettings(BaseModel):
allowBodyCollisions: bool
sharedElimination: bool
sharedHealth: bool
sharedLength: bool
class RulesetSettings(BaseModel):
foodSpawnChance: conint(ge=0, le=100, strict=True)
minimumFood: NonNegativeInt
hazardDamagePerTurn: NonNegativeInt
royale: RoyaleRulesetSettings
squad: SquadRulesetSettings
class Ruleset(BaseModel):
name: GameMode
version: str
settings: RulesetSettings
class Coordinate(BaseModel):
x: NonNegativeInt
y: NonNegativeInt
class BattlesnakeCustomizations(BaseModel):
color: str
head: str
tail: str
class Battlesnake(BaseModel):
id: str
name: str
health: conint(ge=0, le=100, strict=True)
body: List[Coordinate]
latency: str # previous latency in milliseconds, str(int)
head: Coordinate
length: PositiveInt
shout: constr(max_length=256)
squad: str
customizations: BattlesnakeCustomizations
class Board(BaseModel):
height: PositiveInt
width: PositiveInt
food: List[Coordinate]
hazards: List[Coordinate]
snakes: List[Battlesnake]
class Game(BaseModel):
id: str
ruleset: Ruleset
map: GameMap
timeout: NonNegativeInt # milliseconds
source: Source
'''
GET /
'''
class GetResponse(BaseModel):
apiversion: str
author: Union[str, None] = None
color: Union[str, None] = None
head: Union[str, None] = None
tail: Union[str, None] = None
version: Union[str, None] = None
'''
POST /start
'''
class StartRequest(BaseModel):
game: Game
turn: NonNegativeInt
board: Board
you: Battlesnake
class StartResponse(BaseModel):
pass
'''
POST /move
'''
class MoveRequest(BaseModel):
game: Game
turn: NonNegativeInt
board: Board
you: Battlesnake
class MoveResponse(BaseModel):
move: GameMove
shout: constr(max_length=256) = ""
'''
POST /end
'''
class EndRequest(BaseModel):
game: Game
turn: NonNegativeInt
board: Board
you: Battlesnake
class EndResponse(BaseModel):
pass

45
src/server.py Normal file
View File

@ -0,0 +1,45 @@
import logging
import os
from fastapi import FastAPI, Request
from .snakes.snake import Snake
from . import api
snake_name = os.getenv("SNAKE")
if snake_name is None:
print("SNAKE env variable is not set!")
exit(1)
snake = Snake.SNAKES[snake_name]()
app = FastAPI(title=snake.name,
summary=f"API for {snake.name}")
@app.get("/")
def on_info() -> api.GetResponse:
print("INFO")
return snake.info()
@app.post("/start")
def on_start(request: api.StartRequest) -> api.StartResponse:
return snake.start(request)
@app.post("/move")
def on_move(request: api.MoveRequest) -> api.MoveResponse:
return snake.move(request)
@app.post("/end")
def on_end(request: api.EndRequest) -> api.EndResponse:
return snake.end(request)
@app.middleware("http")
async def identify_server(request: Request, call_next):
response = await call_next(request)
response.headers["server"] = "shillerben/gitea/starter-snake-python"
return response
logging.getLogger("werkzeug").setLevel(logging.ERROR)

View File

@ -1,5 +1,7 @@
import typing
from .. import api
class Snake:
@ -9,7 +11,7 @@ class Snake:
def __init_subclass__(cls) -> None:
cls.SNAKES[cls.name] = cls
def info(self) -> typing.Dict:
def info(self) -> api.GetResponse:
'''info is called when you create your Battlesnake on play.battlesnake.com
and controls your Battlesnake's appearance
@ -17,15 +19,15 @@ class Snake:
'''
raise NotImplementedError()
def start(self, game_state: typing.Dict) -> None:
def start(self, game_state: api.StartRequest) -> api.StartResponse:
'''start is called when your Battlesnake begins a game'''
raise NotImplementedError()
def end(self, game_state: typing.Dict) -> None:
def end(self, game_state: api.EndRequest) -> api.EndResponse:
'''end is called when your Battlesnake finishes a game'''
raise NotImplementedError()
def move(self, game_state: typing.Dict) -> typing.Dict:
def move(self, game_state: api.MoveRequest) -> api.MoveResponse:
'''move is called on every turn and returns your next move
Valid moves are "up", "down", "left", or "right"
See https://docs.battlesnake.com/api/example-move for available data

View File

@ -1,6 +1,7 @@
import random
import typing
from .. import api
from .snake import Snake
@ -10,40 +11,41 @@ class StarterSnake(Snake):
name = "starter_snake"
def info(self) -> typing.Dict:
def info(self) -> api.GetResponse:
print("INFO")
return api.GetResponse(
apiversion="1",
author="shillerben",
color="#BBDEFB",
head="default",
tail="default",
)
return {
"apiversion": "1",
"author": "", # TODO: Your Battlesnake Username
"color": "#888888", # TODO: Choose color
"head": "default", # TODO: Choose head
"tail": "default", # TODO: Choose tail
}
def start(self, game_state: typing.Dict):
def start(self, game_state: api.StartRequest) -> api.StartResponse:
print("GAME START")
return api.StartResponse()
def end(self, game_state: typing.Dict):
print("GAME OVER\n")
def end(self, game_state: api.EndRequest) -> api.EndResponse:
print("GAME OVER")
return api.EndResponse()
def move(self, game_state: typing.Dict) -> typing.Dict:
def move(self, game_state: api.MoveRequest) -> api.MoveResponse:
is_move_safe = {"up": True, "down": True, "left": True, "right": True}
# We've included code to prevent your Battlesnake from moving backwards
my_head = game_state["you"]["body"][0] # Coordinates of your head
my_neck = game_state["you"]["body"][1] # Coordinates of your "neck"
my_head = game_state.you.body[0] # Coordinates of your head
my_neck = game_state.you.body[1] # Coordinates of your "neck"
if my_neck["x"] < my_head["x"]: # Neck is left of head, don't move left
if my_neck.x < my_head.x: # Neck is left of head, don't move left
is_move_safe["left"] = False
elif my_neck["x"] > my_head["x"]: # Neck is right of head, don't move right
elif my_neck.x > my_head.x: # Neck is right of head, don't move right
is_move_safe["right"] = False
elif my_neck["y"] < my_head["y"]: # Neck is below head, don't move down
elif my_neck.y < my_head.y: # Neck is below head, don't move down
is_move_safe["down"] = False
elif my_neck["y"] > my_head["y"]: # Neck is above head, don't move up
elif my_neck.y > my_head.y: # Neck is above head, don't move up
is_move_safe["up"] = False
# TODO: Step 1 - Prevent your Battlesnake from moving out of bounds
@ -63,8 +65,8 @@ class StarterSnake(Snake):
safe_moves.append(move)
if len(safe_moves) == 0:
print(f"MOVE {game_state['turn']}: No safe moves detected! Moving down")
return {"move": "down"}
print(f"MOVE {game_state.turn}: No safe moves detected! Moving down")
return api.MoveResponse(move=api.GameMove.UP)
# Choose a random move from the safe ones
next_move = random.choice(safe_moves)
@ -72,5 +74,5 @@ class StarterSnake(Snake):
# TODO: Step 4 - Move towards food instead of random, to regain health and survive longer
# food = game_state['board']['food']
print(f"MOVE {game_state['turn']}: {next_move}")
return {"move": next_move}
print(f"MOVE {game_state.turn}: {next_move}")
return api.MoveResponse(move=api.GameMove(next_move))