Compare commits
	
		
			2 Commits
		
	
	
		
			88be03b689
			...
			6f374aac7f
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 6f374aac7f | |
|  | b414046001 | 
|  | @ -1,2 +0,0 @@ | ||||||
| *.sqlite |  | ||||||
| *.db |  | ||||||
|  | @ -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") | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | from dotenv import load_dotenv | ||||||
|  | import os | ||||||
|  | 
 | ||||||
|  | load_dotenv() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Config: | ||||||
|  |     WORLD_WIDTH = int(os.getenv("WORLD_WIDTH", 1000)) | ||||||
|  |     WORLD_HEIGHT = int(os.getenv("WORLD_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() | ||||||
|  | @ -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 | ||||||
|  | @ -0,0 +1,14 @@ | ||||||
|  | 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: | ||||||
|  |         # don't serialize who owns this | ||||||
|  |         return {} | ||||||
|  | @ -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} | ||||||
|  | @ -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 | ||||||
|  | # | ||||||
|  | # | ||||||
|  | @ -0,0 +1,70 @@ | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  |     def to_dict(self) -> dict: | ||||||
|  |         return { | ||||||
|  |             "entity_type": self.entity_type, | ||||||
|  |             "id": self.id, | ||||||
|  |             "components": {k: v.dict() for k, v in self.components.items()}, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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) | ||||||
|  | 
 | ||||||
|  |     def to_dict(self) -> dict: | ||||||
|  |         return {k: v.to_dict() for k, v in self.entities.items()} | ||||||
|  | @ -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) | ||||||
|  | @ -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") | ||||||
|  | @ -0,0 +1,89 @@ | ||||||
|  | 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): | ||||||
|  |     INITIAL_STATE = "INITIAL_STATE" | ||||||
|  |     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] | ||||||
|  | @ -0,0 +1,32 @@ | ||||||
|  | from enum import Enum | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | from abc import abstractmethod | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SystemType(str, Enum): | ||||||
|  |     NETWORK = "NETWORK" | ||||||
|  |     WORLD = "WORLD" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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) | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | from kennel.engine.systems.system import System, SystemType | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | from kennel.engine.components.component import ComponentType | ||||||
|  | from kennel.app import logger | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class WorldSystem(System): | ||||||
|  |     def __init__(self, width: int, height: int): | ||||||
|  |         super().__init__(SystemType.WORLD) | ||||||
|  |         self.width = width | ||||||
|  |         self.height = height | ||||||
|  | 
 | ||||||
|  |     async def update(self, entity_manager: EntityManager, delta_time: float): | ||||||
|  |         entities = entity_manager.get_entities_with_component(ComponentType.POSITION) | ||||||
|  |         for entity in entities: | ||||||
|  |             position = entity.get_component(ComponentType.POSITION) | ||||||
|  |             if position is None: | ||||||
|  |                 logger.error(f"Entity {entity} has no position component") | ||||||
|  |                 continue | ||||||
|  | 
 | ||||||
|  |             position.x = max(0, min(self.width, position.x)) | ||||||
|  |             position.y = max(0, min(self.height, position.y)) | ||||||
|  | 
 | ||||||
|  |             entity.add_component(position) | ||||||
|  | @ -0,0 +1,80 @@ | ||||||
|  | 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.engine.systems.world import WorldSystem | ||||||
|  | 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: | ||||||
|  |                 component = entity.get_component(component_type) | ||||||
|  |                 if component is not None: | ||||||
|  |                     event.data[component_type] = component.dict() | ||||||
|  | 
 | ||||||
|  |         return event | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | system_manager.add_system(WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT)) | ||||||
|  | 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] | ||||||
|  | @ -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()] | ||||||
							
								
								
									
										157
									
								
								kennel/main.py
								
								
								
								
							
							
						
						
									
										157
									
								
								kennel/main.py
								
								
								
								
							|  | @ -1,57 +1,128 @@ | ||||||
|  | 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.kennel import ( | ||||||
|  |     kennel, | ||||||
|  |     system_manager, | ||||||
|  |     entity_manager, | ||||||
|  |     create_session_controllable_entities, | ||||||
|  | ) | ||||||
|  | from kennel.app import app, templates, logger | ||||||
|  | from kennel.kennelcats import KennelCatService | ||||||
|  | from kennel.middleware import logger_middleware | ||||||
|  | from kennel.config import config | ||||||
|  | import asyncio | ||||||
| import uuid | import uuid | ||||||
| 
 | 
 | ||||||
| import structlog | app.mount("/static", StaticFiles(directory="static"), name="static") | ||||||
| from fastapi import FastAPI, Request, Response |  | ||||||
| from fastapi.staticfiles import StaticFiles |  | ||||||
| from fastapi.templating import Jinja2Templates |  | ||||||
| 
 | 
 | ||||||
| app = FastAPI( | loop = asyncio.get_event_loop() | ||||||
|     servers = [ |  | ||||||
|         {"url": "https://kennel.hatecomputers.club", "description": "prod"} |  | ||||||
|     ] |  | ||||||
| ) |  | ||||||
| logger = structlog.get_logger() |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.middleware("http") | @app.on_event("startup") | ||||||
| async def logger_middleware(request: Request, call_next): | async def startup_event(): | ||||||
|     structlog.contextvars.clear_contextvars() |     logger.info("Starting Kennel...") | ||||||
|     structlog.contextvars.bind_contextvars( |     loop.create_task(kennel.run()) | ||||||
|         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, | @app.on_event("shutdown") | ||||||
|  | async def shutdown_event(): | ||||||
|  |     logger.info("Stopping Kennel...") | ||||||
|  |     kennel.stop() | ||||||
|  |     loop.stop() | ||||||
|  |     logger.info("Kennel stopped") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.get("/") | ||||||
|  | async def index(request: Request): | ||||||
|  |     return templates.TemplateResponse(request=request, name="index.html") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @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} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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}") | ||||||
|  | 
 | ||||||
|  |     await websocket.send_json( | ||||||
|  |         { | ||||||
|  |             "event_type": EventType.INITIAL_STATE, | ||||||
|  |             "data": { | ||||||
|  |                 "world": {"width": config.WORLD_WIDTH, "height": config.WORLD_HEIGHT}, | ||||||
|  |                 "entities": kennel.entity_manager.to_dict(), | ||||||
|  |             }, | ||||||
|  |         } | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     # Exclude /healthcheck endpoint from producing logs |     session_entities = create_session_controllable_entities(session) | ||||||
|     if request.url.path != "/healthcheck": |     try: | ||||||
|         if 400 <= response.status_code < 500: |         network_system = system_manager.get_system(SystemType.NETWORK) | ||||||
|             logger.warn("Client error") |         if network_system is None: | ||||||
|         elif response.status_code >= 500: |             raise "Network system not found" | ||||||
|             logger.error("Server error") |  | ||||||
|         else: |  | ||||||
|             logger.info("OK") |  | ||||||
| 
 | 
 | ||||||
|     return response |         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})) | ||||||
| 
 | 
 | ||||||
| templates = Jinja2Templates(directory="templates") |         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") | @app.get("/healthcheck") | ||||||
| async def healthcheck(): | async def healthcheck(): | ||||||
|     return Response("hello") |     return Response("healthy") | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.get("/") |  | ||||||
| async def read_main(request: Request): |  | ||||||
|     return templates.TemplateResponse( |  | ||||||
|         request=request, name="index.html" |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| app.mount("/static", StaticFiles(directory = "static"), name = "static") |  | ||||||
| 
 |  | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -12,6 +12,9 @@ fastapi = "^0.108.0" | ||||||
| structlog = "^23.2.0" | structlog = "^23.2.0" | ||||||
| uvicorn = { extras = ["standard"], version = "^0.25.0" } | uvicorn = { extras = ["standard"], version = "^0.25.0" } | ||||||
| jinja2 = "^3.1.4" | jinja2 = "^3.1.4" | ||||||
|  | python-dotenv = "^1.0.1" | ||||||
|  | websockets = "^12.0" | ||||||
|  | requests = "^2.32.3" | ||||||
| 
 | 
 | ||||||
| [tool.poetry.group.dev.dependencies] | [tool.poetry.group.dev.dependencies] | ||||||
| ruff = "^0.1.9" | ruff = "^0.1.9" | ||||||
|  |  | ||||||
|  | @ -1,5 +1,12 @@ | ||||||
| window.onload = () => { | window.onload = () => { | ||||||
|     console.log('from js'); |   const ws = new WebSocket("/ws"); | ||||||
|     const kennelWindowEle = document.querySelector('#kennel-window'); |   ws.onopen = () => { | ||||||
|     kennelWindowEle.innerHTML = 'rendered from static/index.js'; |     console.log("connected"); | ||||||
| } |   }; | ||||||
|  |   ws.onmessage = (e) => { | ||||||
|  |     console.log(e); | ||||||
|  |   }; | ||||||
|  |   ws.onclose = (e) => { | ||||||
|  |     console.log("disconnected", e); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -5,10 +5,10 @@ | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <meta http-equiv="X-UA-Compatible" content="ie=edge"> |     <meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||||||
|     <title>Kennel Club</title> |     <title>Kennel Club</title> | ||||||
|  |     <script src='https://unpkg.com/panzoom@9.4.0/dist/panzoom.min.js'></script> | ||||||
|   </head> |   </head> | ||||||
|   <body> |   <body> | ||||||
|     <div id="kennel-window"></div> |     <div id="kennel-window"></div> | ||||||
|     <script src="{{ url_for('static', path='/index.js') }}"></script> |     <script src="{{ url_for('static', path='/index.js') }}"></script> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
| 
 |  | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue