From b4140460014d1fc134df9127600f6da2346376e3 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Tue, 20 Aug 2024 17:34:18 -0700 Subject: init commit with base ECS and Network System --- kennel/app.py | 13 +++ kennel/config.py | 20 +++++ kennel/engine/__init__.py | 0 kennel/engine/components/__init__.py | 0 kennel/engine/components/component.py | 16 ++++ kennel/engine/components/controllable.py | 13 +++ kennel/engine/components/position.py | 14 +++ kennel/engine/entities/__init__.py | 0 kennel/engine/entities/cat.py | 33 +++++++ kennel/engine/entities/entity.py | 60 +++++++++++++ kennel/engine/entities/laser.py | 13 +++ kennel/engine/game.py | 53 +++++++++++ kennel/engine/systems/__init__.py | 0 kennel/engine/systems/network.py | 88 +++++++++++++++++++ kennel/engine/systems/system.py | 31 +++++++ kennel/kennel.py | 81 +++++++++++++++++ kennel/kennelcats.py | 45 ++++++++++ kennel/main.py | 145 ++++++++++++++++++++++--------- kennel/middleware.py | 30 +++++++ 19 files changed, 613 insertions(+), 42 deletions(-) create mode 100644 kennel/app.py create mode 100644 kennel/config.py create mode 100644 kennel/engine/__init__.py create mode 100644 kennel/engine/components/__init__.py create mode 100644 kennel/engine/components/component.py create mode 100644 kennel/engine/components/controllable.py create mode 100644 kennel/engine/components/position.py create mode 100644 kennel/engine/entities/__init__.py create mode 100644 kennel/engine/entities/cat.py create mode 100644 kennel/engine/entities/entity.py create mode 100644 kennel/engine/entities/laser.py create mode 100644 kennel/engine/game.py create mode 100644 kennel/engine/systems/__init__.py create mode 100644 kennel/engine/systems/network.py create mode 100644 kennel/engine/systems/system.py create mode 100644 kennel/kennel.py create mode 100644 kennel/kennelcats.py create mode 100644 kennel/middleware.py (limited to 'kennel') diff --git a/kennel/app.py b/kennel/app.py new file mode 100644 index 0000000..98a0448 --- /dev/null +++ b/kennel/app.py @@ -0,0 +1,13 @@ +import structlog +from fastapi import FastAPI +from fastapi.templating import Jinja2Templates + +app = FastAPI( + servers = [ + {"url": "https://kennel.hatecomputers.club", "description": "prod"} + ] +) + +logger = structlog.get_logger() + +templates = Jinja2Templates(directory="templates") diff --git a/kennel/config.py b/kennel/config.py new file mode 100644 index 0000000..8cec699 --- /dev/null +++ b/kennel/config.py @@ -0,0 +1,20 @@ +from dotenv import load_dotenv +import os + +load_dotenv() + + +class Config: + SIMULATION_WIDTH = int(os.getenv("SIMULATION_WIDTH", 1000)) + SIMULATION_HEIGHT = int(os.getenv("SIMULATION_HEIGHT", 1000)) + + HATECOMPUTERS_ENDPOINT = os.getenv( + "HATECOMPUTERS_ENDPOINT", "https://hatecomputers.club" + ) + + MIN_TIME_STEP = float(os.getenv("MIN_TIME_STEP", 0.05)) + + COOKIE_MAX_AGE = int(os.getenv("COOKIE_MAX_AGE", 60 * 60 * 24 * 7)) # 1 week + + +config = Config() diff --git a/kennel/engine/__init__.py b/kennel/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kennel/engine/components/__init__.py b/kennel/engine/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kennel/engine/components/component.py b/kennel/engine/components/component.py new file mode 100644 index 0000000..c8ef2d0 --- /dev/null +++ b/kennel/engine/components/component.py @@ -0,0 +1,16 @@ +from enum import Enum +from abc import abstractmethod + + +class ComponentType(str, Enum): + POSITION = "POSITION" + CONTROLLABLE = "CONTROLLABLE" + + +class Component: + def __init__(self, component_type: ComponentType): + self.component_type = component_type + + @abstractmethod + def dict(self) -> dict: + pass diff --git a/kennel/engine/components/controllable.py b/kennel/engine/components/controllable.py new file mode 100644 index 0000000..49b0557 --- /dev/null +++ b/kennel/engine/components/controllable.py @@ -0,0 +1,13 @@ +from .component import Component, ComponentType + + +class Controllable(Component): + def __init__(self, by: str): + super().__init__(ComponentType.CONTROLLABLE) + self.by = by + + def __repr__(self) -> str: + return f"Controllable(by={self.by})" + + def dict(self) -> dict: + return {"by": self.by} diff --git a/kennel/engine/components/position.py b/kennel/engine/components/position.py new file mode 100644 index 0000000..2a4da4a --- /dev/null +++ b/kennel/engine/components/position.py @@ -0,0 +1,14 @@ +from .component import Component, ComponentType + + +class Position(Component): + def __init__(self, x: float, y: float): + super().__init__(ComponentType.POSITION) + self.x = x + self.y = y + + def __repr__(self) -> str: + return f"Position(x={self.x}, y={self.y})" + + def dict(self) -> dict: + return {"x": self.x, "y": self.y} diff --git a/kennel/engine/entities/__init__.py b/kennel/engine/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kennel/engine/entities/cat.py b/kennel/engine/entities/cat.py new file mode 100644 index 0000000..02fdb33 --- /dev/null +++ b/kennel/engine/entities/cat.py @@ -0,0 +1,33 @@ +# +# # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY +# state_stochastic_matrix = [ +# # IDLE +# [0.5, 0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0], +# # FROLICKING +# [0.1, 0.5, 0.1, 0.1, 0.1, 0.05, 0.05, 0], +# # EEPY +# [0.1, 0.1, 0.5, 0.1, 0.1, 0.05, 0.05, 0], +# # ALERT +# [0.1, 0.1, 0.1, 0.5, 0.1, 0.05, 0.05, 0], +# # CHASING_CURSOR +# [0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0.05, 0], +# # CHASING_CAT +# [0.1, 0.1, 0.1, 0.1, 0.1, 0.5, 0.05, 0], +# # SCRATCHING +# [0.1, 0.1, 0.1, 0.1, 0.1, 0.05, 0.5, 0], +# # ITCHY +# [0, 0, 0, 0, 0, 0, 0, 1], +# ] +# +# +# class CatState(Enum): +# IDLE = 0 +# FROLICKING = 1 +# EEPY = 2 +# ALERT = 3 +# CHASING_CURSOR = 4 +# CHASING_CAT = 5 +# SCRATCHING = 6 +# ITCHY = 7 +# +# diff --git a/kennel/engine/entities/entity.py b/kennel/engine/entities/entity.py new file mode 100644 index 0000000..0ca8666 --- /dev/null +++ b/kennel/engine/entities/entity.py @@ -0,0 +1,60 @@ +from enum import Enum +from kennel.engine.components.component import Component, ComponentType +from typing import List, Optional + + +class EntityType(str, Enum): + LASER = "LASER" + + +class Entity: + def __init__( + self, entity_type: EntityType, id: str, component_list: List[Component] + ): + self.entity_type = entity_type + self.id = id + self.components = {} + for component in component_list: + self.add_component(component) + + def get_component(self, component_type: ComponentType) -> Optional[Component]: + return self.components[component_type] + + def add_component(self, component: Component) -> None: + self.components[component.component_type] = component + + +class EntityManager: + def __init__(self): + self.entities = {} + self.component_entities = {} + + def update(self) -> None: + self.component_entities = {} + for entity in self.entities.values(): + for component in entity.components.values(): + if component.component_type not in self.component_entities: + self.component_entities[component.component_type] = set() + self.component_entities[component.component_type].add(entity) + + def get_entities_with_component( + self, component_type: ComponentType + ) -> List[Entity]: + return self.component_entities.get(component_type, []) + + def add_entity(self, entity: Entity) -> None: + self.entities[entity.id] = entity + for component in entity.components.values(): + if component.component_type not in self.component_entities: + self.component_entities[component.component_type] = set() + self.component_entities[component.component_type].add(entity) + + def remove_entity(self, entity_id: str) -> None: + if entity_id in self.entities: + entity = self.entities[entity_id] + for component in entity.components.values(): + if component.component_type in self.component_entities: + self.component_entities[component.component_type].remove(entity) + + def get_entity(self, entity_id: str) -> Optional[Entity]: + return self.entities.get(entity_id) diff --git a/kennel/engine/entities/laser.py b/kennel/engine/entities/laser.py new file mode 100644 index 0000000..ead72a6 --- /dev/null +++ b/kennel/engine/entities/laser.py @@ -0,0 +1,13 @@ +from .entity import Entity, EntityType +from kennel.engine.components.position import Position +from kennel.engine.components.controllable import Controllable + + +class Laser(Entity): + def __init__(self, id: str, controllable_by: str): + components = [ + Position(0, 0), + Controllable(controllable_by), + ] + + super().__init__(EntityType.LASER, id, components) diff --git a/kennel/engine/game.py b/kennel/engine/game.py new file mode 100644 index 0000000..2236306 --- /dev/null +++ b/kennel/engine/game.py @@ -0,0 +1,53 @@ +from kennel.engine.entities.entity import EntityManager +from kennel.engine.systems.system import SystemManager +from kennel.app import logger +from typing import List, Optional +import time +import asyncio + + +class Game: + def __init__( + self, + entity_manager: EntityManager, + system_manager: SystemManager, + min_time_step: float, + ): + self.entity_manager = entity_manager + self.system_manager = system_manager + self.min_time_step = min_time_step + + self.last_time = time.time() + self.running = False + + async def update(self, delta_time: float) -> None: + self.entity_manager.update() + await self.system_manager.update(self.entity_manager, delta_time) + + async def run(self) -> None: + logger.info("Game started") + self.running = True + self.last_time = time.time() + steps_since_log = 0 + + while self.running: + current_time = time.time() + if (int(current_time) - int(self.last_time)) == 1 and int( + current_time + ) % 10 == 0: + logger.info(f"Game loop: {steps_since_log} steps since last log time") + steps_since_log = 0 + + delta_time = current_time - self.last_time + self.last_time = current_time + + await self.update(delta_time) + await asyncio.sleep( + max(self.min_time_step - (time.time() - current_time), 0) + ) + + steps_since_log += 1 + + def stop(self) -> None: + self.running = False + logger.info("Game stopped") diff --git a/kennel/engine/systems/__init__.py b/kennel/engine/systems/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kennel/engine/systems/network.py b/kennel/engine/systems/network.py new file mode 100644 index 0000000..7df8088 --- /dev/null +++ b/kennel/engine/systems/network.py @@ -0,0 +1,88 @@ +from enum import Enum +from kennel.engine.entities.entity import EntityManager +from .system import System, SystemType +from abc import abstractmethod +import asyncio + + +class EventType(str, Enum): + ENTITY_BORN = "ENTITY_BORN" + ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE" + ENTITY_DEATH = "ENTITY_DEATH" + + +class Event: + def __init__(self, event_type: EventType, data: dict): + self.event_type = event_type + self.data = data + + def __str__(self): + return f"Event({self.event_type}, {self.data})" + + def dict(self): + return {"event_type": self.event_type, "data": self.data} + + @staticmethod + def from_dict(data: dict): + return Event(EventType(data["event_type"]), data["data"]) + + +class Publishable: + @abstractmethod + async def publish(self, event: Event): + pass + + +class ClientEventTransformer: + @abstractmethod + def apply(self, event: Event) -> Event: + pass + + +class EventProcessor: + @abstractmethod + def accept(self, entity_manager: EntityManager, event: Event) -> None: + pass + + +class NetworkSystem(System): + def __init__( + self, + event_processor: EventProcessor, + client_event_transformer: ClientEventTransformer, + ): + super().__init__(SystemType.NETWORK) + self.event_processor = event_processor + self.client_event_transformer = client_event_transformer + + self.events = [] + self.clients = {} + + async def update(self, entity_manager: EntityManager, delta_time: float) -> None: + for event in self.events: + self.event_processor.accept(entity_manager, event) + client_events = [ + self.client_event_transformer.apply(entity_manager, event) + for event in self.events + ] + await asyncio.gather( + *[ + client.publish(event) + for client in self.clients.values() + for event in client_events + ] + ) + self.events = [] + + def client_event(self, client_id: str, event: Event) -> None: + event.data["client_id"] = client_id + self.events.append(event) + + def add_event(self, event: Event) -> None: + self.events.append(event) + + def add_client(self, client_id: str, client: Publishable) -> None: + self.clients[client_id] = client + + def remove_client(self, client_id: str) -> None: + del self.clients[client_id] diff --git a/kennel/engine/systems/system.py b/kennel/engine/systems/system.py new file mode 100644 index 0000000..0e51f3a --- /dev/null +++ b/kennel/engine/systems/system.py @@ -0,0 +1,31 @@ +from enum import Enum +from kennel.engine.entities.entity import EntityManager +from abc import abstractmethod + + +class SystemType(str, Enum): + NETWORK = "NETWORK" + + +class System: + def __init__(self, system_type: SystemType): + self.system_type = system_type + + @abstractmethod + async def update(self, entity_manager: EntityManager, delta_time: float): + pass + + +class SystemManager: + def __init__(self): + self.systems = {} + + def add_system(self, system: System) -> None: + self.systems[system.system_type] = system + + def get_system(self, system_type: SystemType) -> System: + return self.systems.get(system_type) + + async def update(self, entity_manager: EntityManager, delta_time: float) -> None: + for system in self.systems.values(): + await system.update(entity_manager, delta_time) diff --git a/kennel/kennel.py b/kennel/kennel.py new file mode 100644 index 0000000..0b38261 --- /dev/null +++ b/kennel/kennel.py @@ -0,0 +1,81 @@ +from kennel.engine.game import Game +from kennel.engine.components.component import ComponentType +from kennel.engine.entities.entity import EntityManager, Entity +from kennel.engine.entities.laser import Laser +from kennel.engine.systems.system import SystemManager +from kennel.engine.systems.network import ( + NetworkSystem, + EventProcessor, + ClientEventTransformer, + Event, + EventType, +) +from kennel.config import config +from .app import logger +from typing import List +import uuid + +entity_manager = EntityManager() +system_manager = SystemManager() + + +class KennelEventProcessor(EventProcessor): + def accept(self, entity_manager: EntityManager, event: Event) -> None: + if event.event_type == EventType.ENTITY_POSITION_UPDATE: + self._process_entity_position_update(entity_manager, event) + + def _process_entity_position_update( + self, entity_manager: EntityManager, event: Event + ) -> None: + entity = entity_manager.get_entity(event.data["entity_id"]) + if entity is None: + logger.error(f"Entity {event.data['entity_id']} does not exist") + return + if event.data["client_id"] is None: + logger.error("Client ID is required for position updates") + return + controllable = entity.get_component(ComponentType.CONTROLLABLE) + if controllable is None: + logger.error(f"Entity {entity} is not controllable") + return + if controllable.by != event.data["client_id"]: + logger.error( + f"Entity {entity} is not controllable by client {event.data['client_id']}" + ) + return + position = entity.get_component(ComponentType.POSITION) + if position is None: + logger.error(f"Entity {entity} has no position") + return + entity.add_component(position) + + +class KennelClientEventTransformer(ClientEventTransformer): + def apply(self, entity_manager: EntityManager, event: Event) -> Event: + if event.event_type == EventType.ENTITY_BORN: + id = event.data["id"] + entity = entity_manager.get_entity(id) + if entity is None: + logger.error(f"Entity {id} does not exist") + return event + for component_type in entity.components: + if component_type == ComponentType.CONTROLLABLE: + # Do not send controllable components to clients + continue + component = entity.get_component(component_type) + if component is not None: + event.data[component_type] = component.dict() + + return event + + +system_manager.add_system( + NetworkSystem(EventProcessor(), KennelClientEventTransformer()) +) + +kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP) + + +def create_session_controllable_entities(session: str) -> List[Entity]: + laser = Laser(uuid.uuid4().hex, session) + return [laser] diff --git a/kennel/kennelcats.py b/kennel/kennelcats.py new file mode 100644 index 0000000..340b07e --- /dev/null +++ b/kennel/kennelcats.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import List +import requests + + +class KennelCat: + def __init__( + self, + id: str, + user_id: str, + name: str, + link: str, + description: str, + spritesheet: str, + created_at: datetime, + ): + self.id = id + self.user_id = user_id + self.name = name + self.link = link + self.description = description + self.spritesheet = spritesheet + self.created_at = created_at + + @staticmethod + def from_dict(dict: dict) -> "KennelCat": + return KennelCat( + id=dict["id"], + user_id=dict["user_id"], + name=dict["name"], + link=dict["link"], + description=dict["description"], + spritesheet=dict["spritesheet"], + created_at=datetime.fromisoformat(dict["created_at"]), + ) + + +class KennelCatService: + def __init__(self, endpoint: str): + self.endpoint = endpoint + + def get_kennel(self) -> List[KennelCat]: + response = requests.get(f"{self.endpoint}/kennel") + response.raise_for_status() + return [KennelCat.from_dict(cat) for cat in response.json()] diff --git a/kennel/main.py b/kennel/main.py index 183fb64..122e40a 100644 --- a/kennel/main.py +++ b/kennel/main.py @@ -1,57 +1,118 @@ +from fastapi import ( + FastAPI, + Request, + Response, + WebSocket, + WebSocketException, + status, + Cookie, + Depends, +) +from fastapi.staticfiles import StaticFiles +from kennel.engine.systems.system import SystemType +from kennel.engine.systems.network import Event, Publishable, EventType +from typing import Annotated, Optional +from .kennel import ( + kennel, + system_manager, + entity_manager, + create_session_controllable_entities, +) +from .app import app, templates, logger +from .kennelcats import KennelCatService +from .middleware import logger_middleware +from .config import config +import asyncio import uuid -import structlog -from fastapi import FastAPI, Request, Response -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +app.mount("/static", StaticFiles(directory="static"), name="static") -app = FastAPI( - servers = [ - {"url": "https://kennel.hatecomputers.club", "description": "prod"} - ] -) -logger = structlog.get_logger() +loop = asyncio.get_event_loop() -@app.middleware("http") -async def logger_middleware(request: Request, call_next): - structlog.contextvars.clear_contextvars() - structlog.contextvars.bind_contextvars( - path=request.url.path, - method=request.method, - client_host=request.client.host, - request_id=str(uuid.uuid4()), - ) - response = await call_next(request) +@app.on_event("startup") +async def startup_event(): + logger.info("Starting Kennel...") + loop.create_task(kennel.run()) - structlog.contextvars.bind_contextvars( - status_code=response.status_code, - ) - # Exclude /healthcheck endpoint from producing logs - if request.url.path != "/healthcheck": - if 400 <= response.status_code < 500: - logger.warn("Client error") - elif response.status_code >= 500: - logger.error("Server error") - else: - logger.info("OK") +@app.on_event("shutdown") +async def shutdown_event(): + logger.info("Stopping Kennel...") + kennel.stop() + loop.stop() + logger.info("Kennel stopped") - return response -templates = Jinja2Templates(directory="templates") +@app.get("/") +async def index(request: Request): + return templates.TemplateResponse(request=request, name="index.html") -@app.get("/healthcheck") -async def healthcheck(): - return Response("hello") +@app.get("/assign") +async def assign( + response: Response, + session: Annotated[Optional[str], Cookie()] = None, +): + if session is None: + session = str(uuid.uuid4().hex) + response.set_cookie(key="session", value=session, max_age=config.COOKIE_MAX_AGE) + return {"session": session} -@app.get("/") -async def read_main(request: Request): - return templates.TemplateResponse( - request=request, name="index.html" - ) +async def get_cookie_or_token( + websocket: WebSocket, + session: Annotated[str | None, Cookie()] = None, +): + if session is None: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) + return session + -app.mount("/static", StaticFiles(directory = "static"), name = "static") +class WebSocketClient(Publishable): + def __init__(self, websocket: WebSocket): + self.websocket = websocket + async def publish(self, event: Event): + await self.websocket.send_json(event.dict()) + + +@app.websocket("/ws") +async def websocket_endpoint( + websocket: WebSocket, + session: Annotated[str, Depends(get_cookie_or_token)], +): + await websocket.accept() + logger.info(f"Websocket connection established for session {session}") + + session_entities = create_session_controllable_entities(session) + try: + network_system = system_manager.get_system(SystemType.NETWORK) + if network_system is None: + raise "Network system not found" + + for entity in session_entities: + logger.info(f"Adding entity {entity.id} for session {session}") + entity_manager.add_entity(entity) + network_system.add_event(Event(EventType.ENTITY_BORN, {"id": entity.id})) + + network_system.add_client(session, WebSocketClient(websocket)) + while True: + message = await websocket.receive_json() + network_system.client_event(session, Event.from_dict(message)) + except Exception as e: + logger.error(f"WebSocket exception {e}") + finally: + logger.info("Websocket connection closed") + for entity in session_entities: + logger.info(f"Removing entity {entity.id} for session {session}") + entity_manager.remove_entity(entity.id) + network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id})) + + network_system.remove_client(session) + await websocket.close() + + +@app.get("/healthcheck") +async def healthcheck(): + return Response("healthy") diff --git a/kennel/middleware.py b/kennel/middleware.py new file mode 100644 index 0000000..dddf63b --- /dev/null +++ b/kennel/middleware.py @@ -0,0 +1,30 @@ +import uuid +import structlog +from fastapi import Request, Response +from .app import app, logger + +@app.middleware("http") +async def logger_middleware(request: Request, call_next): + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + path=request.url.path, + method=request.method, + client_host=request.client.host, + request_id=str(uuid.uuid4()), + ) + response = await call_next(request) + + structlog.contextvars.bind_contextvars( + status_code=response.status_code, + ) + + # Exclude /healthcheck endpoint from producing logs + if request.url.path != "/healthcheck": + if 400 <= response.status_code < 500: + logger.warn("Client error") + elif response.status_code >= 500: + logger.error("Server error") + else: + logger.info("OK") + + return response -- cgit v1.3