WIP: ECS / Network System #1
			
				
			
		
		
		
	|  | @ -4,12 +4,12 @@ type: docker | ||||||
| name: build | name: build | ||||||
| 
 | 
 | ||||||
| steps: | steps: | ||||||
|   - name: run tests |   - name: lint | ||||||
|     image: python:3.12 |     image: python:3.12 | ||||||
|     commands: |     commands: | ||||||
|       - pip install poetry |       - pip install poetry | ||||||
|       - poetry install --with main,dev |       - poetry install --with main,dev | ||||||
|       - poetry run pytest |       - poetry run ruff check kennel/* | ||||||
| 
 | 
 | ||||||
| trigger: | trigger: | ||||||
|   event: |   event: | ||||||
|  | @ -21,12 +21,12 @@ type: docker | ||||||
| name: deploy | name: deploy | ||||||
| 
 | 
 | ||||||
| steps: | steps: | ||||||
|   - name: run tests |   - name: run lier | ||||||
|     image: python:3.12 |     image: python:3.12 | ||||||
|     commands: |     commands: | ||||||
|       - pip install poetry |       - pip install poetry | ||||||
|       - poetry install --with main,dev |       - poetry install --with main,dev | ||||||
|       - poetry run pytest |       - poetry run ruff check kennel/* | ||||||
|   - name: docker |   - name: docker | ||||||
|     image: plugins/docker |     image: plugins/docker | ||||||
|     settings: |     settings: | ||||||
|  |  | ||||||
|  | @ -1,2 +0,0 @@ | ||||||
| *.sqlite |  | ||||||
| *.db |  | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | import structlog | ||||||
|  | from fastapi import FastAPI | ||||||
|  | 
 | ||||||
|  | app = FastAPI( | ||||||
|  |     servers=[{"url": "https://kennel.hatecomputers.club", "description": "prod"}] | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | logger = structlog.get_logger() | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | import os | ||||||
|  | 
 | ||||||
|  | from dotenv import load_dotenv | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  |     KENNEL_CATS_POLL_SEC = int(os.getenv("KENNEL_CATS_POLL_SEC", 10)) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | config = Config() | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | from abc import abstractmethod | ||||||
|  | from enum import Enum | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ComponentType(str, Enum): | ||||||
|  |     POSITION = "POSITION" | ||||||
|  |     CONTROLLABLE = "CONTROLLABLE" | ||||||
|  |     MARKOV = "MARKOV" | ||||||
|  |     SPRITESHEET = "SPRITESHEET" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Component: | ||||||
|  |     def __init__(self, component_type: ComponentType): | ||||||
|  |         self.component_type = component_type | ||||||
|  | 
 | ||||||
|  |     @abstractmethod | ||||||
|  |     def to_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 to_dict(self) -> dict: | ||||||
|  |         # don't serialize who owns this | ||||||
|  |         return {} | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | from kennel.engine.components.component import Component, ComponentType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MarkovTransitionState(Component): | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         state_names: dict[int, str], | ||||||
|  |         initial_state_vector: list[float], | ||||||
|  |         transition_matrix: list[list[float]], | ||||||
|  |     ): | ||||||
|  |         # TODO: Poll rate per state? | ||||||
|  |         # TODO: State being an enum instead of a vector, just choose max and map | ||||||
|  |         self.state_names = state_names | ||||||
|  |         self.state = initial_state_vector | ||||||
|  |         self.transition_matrix = transition_matrix | ||||||
|  | 
 | ||||||
|  |         super().__init__(ComponentType.MARKOV) | ||||||
|  | 
 | ||||||
|  |     def get_max_state_name(self, state_vector: list[float]): | ||||||
|  |         max_val = max(state_vector) | ||||||
|  |         return self.state_names[state_vector.index(max_val)] | ||||||
|  | 
 | ||||||
|  |     def to_dict(self): | ||||||
|  |         return {"state": self.get_max_state_name(self.state)} | ||||||
|  | @ -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 to_dict(self) -> dict: | ||||||
|  |         return {"x": self.x, "y": self.y} | ||||||
|  | @ -0,0 +1,63 @@ | ||||||
|  | from .component import Component, ComponentType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SpriteSpec: | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         ms_per_frame: int, | ||||||
|  |         top_x: int, | ||||||
|  |         top_y: int, | ||||||
|  |         end_x: int, | ||||||
|  |         end_y: int, | ||||||
|  |         frames: int, | ||||||
|  |     ): | ||||||
|  |         self.ms_per_frame = ms_per_frame | ||||||
|  |         self.frames = frames | ||||||
|  |         self.top_x = top_x | ||||||
|  |         self.top_y = top_y | ||||||
|  |         self.end_x = end_x | ||||||
|  |         self.end_y = end_y | ||||||
|  | 
 | ||||||
|  |     def to_dict(self) -> dict: | ||||||
|  |         return { | ||||||
|  |             "ms_per_frame": self.ms_per_frame, | ||||||
|  |             "top_x": self.top_x, | ||||||
|  |             "top_y": self.top_y, | ||||||
|  |             "end_x": self.end_x, | ||||||
|  |             "end_y": self.end_y, | ||||||
|  |             "frames": self.frames, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         return f"SpriteSpec(ms_per_frame={self.ms_per_frame}, top_x={self.top_x}, top_y={self.top_y}, end_x={self.end_x}, end_y={self.end_y}, frames={self.frames})" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SpriteSheet(Component): | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         source: str, | ||||||
|  |         state_to_spritespec: dict[str, SpriteSpec], | ||||||
|  |         initial_state: str, | ||||||
|  |     ): | ||||||
|  |         super().__init__(ComponentType.SPRITESHEET) | ||||||
|  |         self.source = source | ||||||
|  |         self.state_to_spritespec = state_to_spritespec | ||||||
|  | 
 | ||||||
|  |         # these are only really used for client initialization | ||||||
|  |         self.initial_state = initial_state | ||||||
|  |         self.current_frame = 0 | ||||||
|  |         self.last_update = 0 | ||||||
|  | 
 | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         return f"SpriteSheet(source={self.source}, state_to_spritespec={self.state_to_spritespec}, initial_state={self.initial_state}, current_frame={self.current_frame}, last_update={self.last_update})" | ||||||
|  | 
 | ||||||
|  |     def to_dict(self) -> dict: | ||||||
|  |         return { | ||||||
|  |             "source": self.source, | ||||||
|  |             "current_frame": self.current_frame, | ||||||
|  |             "last_update": self.last_update, | ||||||
|  |             "current_state": self.initial_state, | ||||||
|  |             "state_to_spritespec": { | ||||||
|  |                 k: v.to_dict() for k, v in self.state_to_spritespec.items() | ||||||
|  |             }, | ||||||
|  |         } | ||||||
|  | @ -0,0 +1,83 @@ | ||||||
|  | from kennel.engine.components.position import Position | ||||||
|  | from kennel.engine.components.sprite_sheet import SpriteSheet, SpriteSpec | ||||||
|  | from enum import Enum | ||||||
|  | from .entity import Entity, EntityType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class CatState(str, Enum): | ||||||
|  |     IDLE = "IDLE" | ||||||
|  |     FROLICKING = "FROLICKING" | ||||||
|  |     EEPY = "EEPY" | ||||||
|  |     ALERT = "ALERT" | ||||||
|  |     CHASING_CURSOR = "CHASING_CURSOR" | ||||||
|  |     CHASING_CAT = "CHASING_CAT" | ||||||
|  |     SCRATCHING = "SCRATCHING" | ||||||
|  |     ITCHY = "ITCHY" | ||||||
|  |     MAKING_BISCUITS = "MAKING_BISCUITS" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class CatSpriteState(str, Enum): | ||||||
|  |     ALERT = "ALERT" | ||||||
|  |     MAKING_BISCUITS = "MAKING_BISCUITS" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Cat(Entity): | ||||||
|  |     def __init__(self, id: str, spritesheet_source: str): | ||||||
|  |         state_to_spritespec = self.get_state_to_spritespec() | ||||||
|  |         components = [ | ||||||
|  |             Position(50, 50), | ||||||
|  |             SpriteSheet( | ||||||
|  |                 spritesheet_source, state_to_spritespec, CatSpriteState.MAKING_BISCUITS | ||||||
|  |             ), | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |         super().__init__(EntityType.CAT, id, components) | ||||||
|  | 
 | ||||||
|  |     def get_state_to_spritespec(self): | ||||||
|  |         creature_width = 32 | ||||||
|  |         creature_height = 32 | ||||||
|  |         return { | ||||||
|  |             CatSpriteState.ALERT: SpriteSpec( | ||||||
|  |                 100, | ||||||
|  |                 creature_width * 7, | ||||||
|  |                 creature_height * 3, | ||||||
|  |                 creature_width * 8, | ||||||
|  |                 creature_height * 4, | ||||||
|  |                 1, | ||||||
|  |             ), | ||||||
|  |             CatSpriteState.MAKING_BISCUITS: SpriteSpec( | ||||||
|  |                 300, | ||||||
|  |                 0, | ||||||
|  |                 0, | ||||||
|  |                 creature_width, | ||||||
|  |                 creature_height * 2, | ||||||
|  |                 2, | ||||||
|  |             ), | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | # | ||||||
|  | # # IDLE, FROLICKING, EEPY, ALERT, CHASING_CURSOR, CHASING_CAT, SCRATCHING, ITCHY | ||||||
|  | # state_stochastic_matrix = [ [1, 0] | ||||||
|  | #     # 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], | ||||||
|  | # ] | ||||||
|  | # | ||||||
|  | # | ||||||
|  | 
 | ||||||
|  | # | ||||||
|  | # | ||||||
|  | @ -0,0 +1,74 @@ | ||||||
|  | from enum import Enum | ||||||
|  | from typing import List, Optional | ||||||
|  | 
 | ||||||
|  | from kennel.engine.components.component import Component, ComponentType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EntityType(str, Enum): | ||||||
|  |     LASER = "LASER" | ||||||
|  |     CAT = "CAT" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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.to_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 not in self.entities: | ||||||
|  |             return | ||||||
|  |         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) | ||||||
|  |         del self.entities[entity_id] | ||||||
|  | 
 | ||||||
|  |     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,14 @@ | ||||||
|  | from kennel.engine.components.controllable import Controllable | ||||||
|  | from kennel.engine.components.position import Position | ||||||
|  | 
 | ||||||
|  | from .entity import Entity, EntityType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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 @@ | ||||||
|  | import asyncio | ||||||
|  | import time | ||||||
|  | 
 | ||||||
|  | from kennel.app import logger | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | from kennel.engine.systems.system import SystemManager | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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) | ||||||
|  |             sleep_time = max(self.min_time_step - (time.time() - current_time), 0) | ||||||
|  |             if sleep_time > 0: | ||||||
|  |                 await asyncio.sleep(sleep_time) | ||||||
|  | 
 | ||||||
|  |             steps_since_log += 1 | ||||||
|  | 
 | ||||||
|  |     def stop(self) -> None: | ||||||
|  |         self.running = False | ||||||
|  |         logger.info("Game stopped") | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | from kennel.engine.components.component import ComponentType | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | from kennel.engine.systems.system import System, SystemType | ||||||
|  | from kennel.engine.systems.network import NetworkSystem | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MarkovTransitionStateSystem(System): | ||||||
|  |     def __init__(self, network_system: NetworkSystem): | ||||||
|  |         super().__init__(SystemType.MARKOV) | ||||||
|  | 
 | ||||||
|  |     def update(self, entity_manager: EntityManager, delta_time: float): | ||||||
|  |         entity_manager.get_entities_with_component(ComponentType.MARKOV) | ||||||
|  |         return | ||||||
|  | @ -0,0 +1,165 @@ | ||||||
|  | import asyncio | ||||||
|  | from abc import abstractmethod | ||||||
|  | from enum import Enum | ||||||
|  | from typing import Dict, List, Optional | ||||||
|  | 
 | ||||||
|  | from kennel.app import logger | ||||||
|  | from kennel.engine.entities.entity import Entity, EntityManager | ||||||
|  | 
 | ||||||
|  | from .system import System, SystemType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EventType(str, Enum): | ||||||
|  |     INITIAL_STATE = "INITIAL_STATE" | ||||||
|  |     SET_CONTROLLABLE = "SET_CONTROLLABLE" | ||||||
|  |     ENTITY_BORN = "ENTITY_BORN" | ||||||
|  |     ENTITY_DEATH = "ENTITY_DEATH" | ||||||
|  |     ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Event: | ||||||
|  |     def __init__(self, event_type: EventType, data: dict): | ||||||
|  |         self.event_type = event_type | ||||||
|  |         self.data = data | ||||||
|  | 
 | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"Event({self.event_type}, {self.data})" | ||||||
|  | 
 | ||||||
|  |     def to_dict(self) -> dict: | ||||||
|  |         return {"event_type": self.event_type, "data": self.data} | ||||||
|  | 
 | ||||||
|  |     @staticmethod | ||||||
|  |     def from_dict(data: dict) -> Optional["Event"]: | ||||||
|  |         if "event_type" not in data or "data" not in data: | ||||||
|  |             return | ||||||
|  |         event_type = data["event_type"] | ||||||
|  |         if event_type == EventType.INITIAL_STATE: | ||||||
|  |             return InitialStateEvent( | ||||||
|  |                 data["data"]["world"]["width"], | ||||||
|  |                 data["data"]["world"]["height"], | ||||||
|  |                 data["data"]["entities"], | ||||||
|  |             ) | ||||||
|  |         if event_type == EventType.SET_CONTROLLABLE: | ||||||
|  |             return SetControllableEvent(data["data"]["id"], data["data"]["client_id"]) | ||||||
|  |         if event_type == EventType.ENTITY_BORN: | ||||||
|  |             return EntityBornEvent(data["data"]) | ||||||
|  |         if event_type == EventType.ENTITY_POSITION_UPDATE: | ||||||
|  |             return EntityPositionUpdateEvent( | ||||||
|  |                 data["data"]["id"], data["data"]["position"] | ||||||
|  |             ) | ||||||
|  |         if event_type == EventType.ENTITY_DEATH: | ||||||
|  |             return EntityDeathEvent(data["data"]["id"]) | ||||||
|  | 
 | ||||||
|  |         logger.warn(f"Unknown event type: {data['event_type']}") | ||||||
|  |         return Event(EventType(data["event_type"]), data["data"]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class InitialStateEvent(Event): | ||||||
|  |     def __init__( | ||||||
|  |         self, world_width: int, world_height: int, entities: Dict[str, Entity] | ||||||
|  |     ): | ||||||
|  |         self.world_width = world_width | ||||||
|  |         self.world_height = world_height | ||||||
|  |         self.entities = entities | ||||||
|  |         super().__init__( | ||||||
|  |             EventType.INITIAL_STATE, | ||||||
|  |             { | ||||||
|  |                 "world": {"width": world_width, "height": world_height}, | ||||||
|  |                 "entities": entities, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SetControllableEvent(Event): | ||||||
|  |     def __init__(self, entity_id: str, client_id: str): | ||||||
|  |         self.entity_id = entity_id | ||||||
|  |         self.client_id = client_id | ||||||
|  |         super().__init__( | ||||||
|  |             EventType.SET_CONTROLLABLE, {"id": entity_id, "client_id": client_id} | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EntityBornEvent(Event): | ||||||
|  |     def __init__(self, entity: Entity): | ||||||
|  |         self.entity = entity | ||||||
|  |         super().__init__(EventType.ENTITY_BORN, {"entity": entity.to_dict()}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EntityPositionUpdateEvent(Event): | ||||||
|  |     def __init__(self, entity_id: str, position: dict): | ||||||
|  |         super().__init__( | ||||||
|  |             EventType.ENTITY_POSITION_UPDATE, | ||||||
|  |             {"id": entity_id, "position": position}, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class EntityDeathEvent(Event): | ||||||
|  |     def __init__(self, entity_id: str): | ||||||
|  |         super().__init__(EventType.ENTITY_DEATH, {"id": entity_id}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Publishable: | ||||||
|  |     @abstractmethod | ||||||
|  |     async def publish(self, event: List[Event]): | ||||||
|  |         pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class UpstreamEventProcessor: | ||||||
|  |     @abstractmethod | ||||||
|  |     def accept( | ||||||
|  |         self, entity_manager: EntityManager, client_event: tuple[Event, str] | ||||||
|  |     ) -> Optional[Event]: | ||||||
|  |         pass | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class NetworkSystem(System): | ||||||
|  |     event_processor: UpstreamEventProcessor | ||||||
|  |     sever_events: List[Event] | ||||||
|  |     client_upstream_events: List[tuple[Event, str]] | ||||||
|  |     clients: Dict[str, tuple[Publishable, List[Event]]] | ||||||
|  | 
 | ||||||
|  |     def __init__(self, upstream_event_processor: UpstreamEventProcessor): | ||||||
|  |         super().__init__(SystemType.NETWORK) | ||||||
|  |         self.upstream_event_processor = upstream_event_processor | ||||||
|  | 
 | ||||||
|  |         self.server_events = []  # events to propogate to the entire network | ||||||
|  |         self.client_upstream_events = []  # events that come from the clients | ||||||
|  | 
 | ||||||
|  |         self.clients = {} | ||||||
|  | 
 | ||||||
|  |     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||||
|  |         for event in self.client_upstream_events: | ||||||
|  |             produced_event = self.upstream_event_processor.accept(entity_manager, event) | ||||||
|  |             if produced_event is not None: | ||||||
|  |                 self.server_events.append(produced_event) | ||||||
|  |         promises = [ | ||||||
|  |             client.publish(self.server_events + events) | ||||||
|  |             for client, events in self.clients.values() | ||||||
|  |             if len(self.server_events + events) > 0 | ||||||
|  |         ] | ||||||
|  |         await asyncio.gather(*promises) | ||||||
|  | 
 | ||||||
|  |         self.server_events = [] | ||||||
|  |         self.client_upstream_events = [] | ||||||
|  |         for client_id in self.clients.keys(): | ||||||
|  |             (client, _) = self.clients[client_id] | ||||||
|  |             self.clients[client_id] = (client, []) | ||||||
|  | 
 | ||||||
|  |     def server_global_event(self, event: Event) -> None: | ||||||
|  |         self.server_events.append(event) | ||||||
|  | 
 | ||||||
|  |     def client_upstream_event(self, client_id: str, event: Event) -> None: | ||||||
|  |         self.client_upstream_events.append((event, client_id)) | ||||||
|  | 
 | ||||||
|  |     def client_downstream_event(self, client_id: str, event: Event) -> None: | ||||||
|  |         if client_id not in self.clients: | ||||||
|  |             logger.info(f"client {client_id} not found") | ||||||
|  |             return | ||||||
|  |         (client, events) = self.clients[client_id] | ||||||
|  |         self.clients[client_id] = (client, events + [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,34 @@ | ||||||
|  | from abc import abstractmethod | ||||||
|  | from enum import Enum | ||||||
|  | 
 | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class SystemType(str, Enum): | ||||||
|  |     NETWORK = "NETWORK" | ||||||
|  |     WORLD = "WORLD" | ||||||
|  |     MARKOV = "MARKOV" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class System: | ||||||
|  |     def __init__(self, system_type: SystemType): | ||||||
|  |         self.system_type = system_type | ||||||
|  | 
 | ||||||
|  |     @abstractmethod | ||||||
|  |     async def update(self, entity_manager: EntityManager, delta_time: float) -> None: | ||||||
|  |         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.app import logger | ||||||
|  | from kennel.engine.components.component import ComponentType | ||||||
|  | from kennel.engine.entities.entity import EntityManager | ||||||
|  | from kennel.engine.systems.system import System, SystemType | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 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,143 @@ | ||||||
|  | import asyncio | ||||||
|  | import uuid | ||||||
|  | import time | ||||||
|  | from typing import List, Optional | ||||||
|  | 
 | ||||||
|  | from kennel.config import config | ||||||
|  | from kennel.engine.components.component import ComponentType | ||||||
|  | from kennel.engine.entities.entity import Entity, EntityManager | ||||||
|  | from kennel.engine.entities.laser import Laser | ||||||
|  | from kennel.engine.entities.cat import Cat | ||||||
|  | from kennel.engine.game import Game | ||||||
|  | from kennel.engine.systems.markov_transition_state_system import ( | ||||||
|  |     MarkovTransitionStateSystem, | ||||||
|  | ) | ||||||
|  | from kennel.engine.systems.network import ( | ||||||
|  |     EntityPositionUpdateEvent, | ||||||
|  |     EntityBornEvent, | ||||||
|  |     EntityDeathEvent, | ||||||
|  |     Event, | ||||||
|  |     EventType, | ||||||
|  |     NetworkSystem, | ||||||
|  |     UpstreamEventProcessor, | ||||||
|  | ) | ||||||
|  | from kennel.kennelcats import KennelCatService, KennelCat | ||||||
|  | from kennel.engine.systems.system import SystemManager | ||||||
|  | from kennel.engine.systems.world import WorldSystem | ||||||
|  | 
 | ||||||
|  | from .app import logger | ||||||
|  | 
 | ||||||
|  | entity_manager = EntityManager() | ||||||
|  | system_manager = SystemManager() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class KennelEventProcessor(UpstreamEventProcessor): | ||||||
|  |     def accept( | ||||||
|  |         self, entity_manager: EntityManager, event: tuple[Event, str] | ||||||
|  |     ) -> Optional[Event]: | ||||||
|  |         client_event, client_id = event | ||||||
|  |         if client_event.event_type == EventType.ENTITY_POSITION_UPDATE: | ||||||
|  |             return self._process_entity_position_update( | ||||||
|  |                 entity_manager, client_event, client_id | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |     def _process_entity_position_update( | ||||||
|  |         self, | ||||||
|  |         entity_manager: EntityManager, | ||||||
|  |         event: EntityPositionUpdateEvent, | ||||||
|  |         client_id: str, | ||||||
|  |     ) -> Optional[Event]: | ||||||
|  |         entity = entity_manager.get_entity(event.data["id"]) | ||||||
|  |         if entity is None: | ||||||
|  |             logger.error(f"Entity(id={event.data['id']}) does not exist") | ||||||
|  |             return | ||||||
|  |         controllable = entity.get_component(ComponentType.CONTROLLABLE) | ||||||
|  |         if controllable is None or controllable.by != client_id: | ||||||
|  |             logger.error(f"Entity {entity} is not controllable by client {client_id}") | ||||||
|  |             return | ||||||
|  |         position = entity.get_component(ComponentType.POSITION) | ||||||
|  |         if position is None: | ||||||
|  |             logger.error(f"Entity {entity} has no position") | ||||||
|  |             return | ||||||
|  |         position.x = event.data["position"]["x"] | ||||||
|  |         position.y = event.data["position"]["y"] | ||||||
|  | 
 | ||||||
|  |         entity.add_component(position) | ||||||
|  |         entity_manager.add_entity(entity) | ||||||
|  |         return event | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class KennelCatsManager: | ||||||
|  |     kennel_cat_service: KennelCatService | ||||||
|  |     entity_manager: EntityManager | ||||||
|  |     network_system: NetworkSystem | ||||||
|  |     last_seen: set[str] | ||||||
|  |     poll_interval_sec: int | ||||||
|  |     running: bool | ||||||
|  | 
 | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         kennel_cat_service: KennelCatService, | ||||||
|  |         entity_manager: EntityManager, | ||||||
|  |         network_system: NetworkSystem, | ||||||
|  |         poll_interval_sec: int, | ||||||
|  |     ): | ||||||
|  |         self.kennel_cat_service = kennel_cat_service | ||||||
|  |         self.entity_manager = entity_manager | ||||||
|  |         self.network_system = network_system | ||||||
|  |         self.poll_interval_sec = poll_interval_sec | ||||||
|  | 
 | ||||||
|  |         self.last_seen = set() | ||||||
|  |         self.running = False | ||||||
|  | 
 | ||||||
|  |     async def start(self) -> None: | ||||||
|  |         logger.info("starting kennel cats manager") | ||||||
|  |         if self.running: | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         self.running = True | ||||||
|  |         while self.running: | ||||||
|  |             logger.info("polling kennel cats service") | ||||||
|  |             cats = self.kennel_cat_service.get_kennel() | ||||||
|  |             cats_table = {cat.id: cat for cat in cats} | ||||||
|  |             cat_ids = set([cat.id for cat in cats]) | ||||||
|  | 
 | ||||||
|  |             removed_cats = [cats_table[id] for id in self.last_seen.difference(cat_ids)] | ||||||
|  |             added_cats = [cats_table[id] for id in cat_ids.difference(self.last_seen)] | ||||||
|  |             logger.info(f"removing {removed_cats}, adding {added_cats}") | ||||||
|  | 
 | ||||||
|  |             for removed in removed_cats: | ||||||
|  |                 self.entity_manager.remove_entity(removed) | ||||||
|  |                 entity_death = EntityDeathEvent(removed.id) | ||||||
|  |                 self.network_system.server_global_event(entity_death) | ||||||
|  | 
 | ||||||
|  |             for added in added_cats: | ||||||
|  |                 new_cat = Cat(added.id, added.spritesheet) | ||||||
|  |                 self.entity_manager.add_entity(new_cat) | ||||||
|  |                 entity_born = EntityBornEvent(new_cat) | ||||||
|  |                 self.network_system.server_global_event(entity_born) | ||||||
|  | 
 | ||||||
|  |             self.last_seen = cat_ids | ||||||
|  |             await asyncio.sleep(self.poll_interval_sec) | ||||||
|  | 
 | ||||||
|  |     def stop(self) -> None: | ||||||
|  |         logger.info("stopping kennel cats manager") | ||||||
|  |         self.running = False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | network_system = NetworkSystem(KennelEventProcessor()) | ||||||
|  | world_system = WorldSystem(config.WORLD_WIDTH, config.WORLD_HEIGHT) | ||||||
|  | markov_transition_state_system = MarkovTransitionStateSystem(network_system) | ||||||
|  | system_manager.add_system(network_system) | ||||||
|  | system_manager.add_system(world_system) | ||||||
|  | 
 | ||||||
|  | kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP) | ||||||
|  | kennel_cat_service = KennelCatService(config.HATECOMPUTERS_ENDPOINT) | ||||||
|  | kennel_cats_manager = KennelCatsManager( | ||||||
|  |     kennel_cat_service, entity_manager, network_system, config.KENNEL_CATS_POLL_SEC | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def create_session_controllable_entities(session: str) -> List[Entity]: | ||||||
|  |     laser = Laser(uuid.uuid4().hex[:10], session) | ||||||
|  |     return [laser] | ||||||
|  | @ -0,0 +1,49 @@ | ||||||
|  | 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, hc_endpoint: str): | ||||||
|  |         self.hc_endpoint = hc_endpoint | ||||||
|  | 
 | ||||||
|  |     def get_kennel(self) -> List[KennelCat]: | ||||||
|  |         response = requests.get(f"{self.hc_endpoint}/kennel") | ||||||
|  |         response.raise_for_status() | ||||||
|  |         cats = [KennelCat.from_dict(cat) for cat in response.json()] | ||||||
|  |         for cat in cats: | ||||||
|  |             cat.spritesheet = self.hc_endpoint + cat.spritesheet | ||||||
|  |         return cats | ||||||
							
								
								
									
										182
									
								
								kennel/main.py
								
								
								
								
							
							
						
						
									
										182
									
								
								kennel/main.py
								
								
								
								
							|  | @ -1,57 +1,151 @@ | ||||||
|  | import asyncio | ||||||
| import uuid | import uuid | ||||||
|  | from typing import Annotated, List, Optional | ||||||
| 
 | 
 | ||||||
| import structlog | from fastapi import ( | ||||||
| from fastapi import FastAPI, Request, Response |     Cookie, | ||||||
| from fastapi.staticfiles import StaticFiles |     Depends, | ||||||
| from fastapi.templating import Jinja2Templates |     Request, | ||||||
| 
 |     Response, | ||||||
| app = FastAPI( |     WebSocket, | ||||||
|     servers = [ |     WebSocketDisconnect, | ||||||
|         {"url": "https://kennel.hatecomputers.club", "description": "prod"} |     WebSocketException, | ||||||
|     ] |     status, | ||||||
| ) | ) | ||||||
| logger = structlog.get_logger() | from fastapi.responses import FileResponse | ||||||
|  | from fastapi.staticfiles import StaticFiles | ||||||
|  | 
 | ||||||
|  | from kennel.app import app, logger | ||||||
|  | from kennel.config import config | ||||||
|  | from kennel.engine.systems.network import ( | ||||||
|  |     EntityBornEvent, | ||||||
|  |     Event, | ||||||
|  |     EventType, | ||||||
|  |     InitialStateEvent, | ||||||
|  |     Publishable, | ||||||
|  |     SetControllableEvent, | ||||||
|  | ) | ||||||
|  | from kennel.engine.systems.system import SystemType | ||||||
|  | from kennel.kennel import ( | ||||||
|  |     create_session_controllable_entities, | ||||||
|  |     entity_manager, | ||||||
|  |     kennel, | ||||||
|  |     kennel_cats_manager, | ||||||
|  |     system_manager, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | app.mount("/static", StaticFiles(directory="static/dist"), name="static") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.middleware("http") | loop = asyncio.get_event_loop() | ||||||
| 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 | @app.on_event("startup") | ||||||
|     if request.url.path != "/healthcheck": | async def startup_event(): | ||||||
|         if 400 <= response.status_code < 500: |     logger.info("Starting Kennel...") | ||||||
|             logger.warn("Client error") |     loop.create_task(kennel.run()) | ||||||
|         elif response.status_code >= 500: |     loop.create_task(kennel_cats_manager.start()) | ||||||
|             logger.error("Server error") |  | ||||||
|         else: |  | ||||||
|             logger.info("OK") |  | ||||||
| 
 | 
 | ||||||
|     return response |  | ||||||
| 
 | 
 | ||||||
| templates = Jinja2Templates(directory="templates") | @app.on_event("shutdown") | ||||||
|  | async def shutdown_event(): | ||||||
|  |     logger.info("Stopping Kennel...") | ||||||
|  |     kennel.stop() | ||||||
|  |     kennel_cats_manager.stop() | ||||||
|  |     loop.stop() | ||||||
|  |     logger.info("Kennel stopped") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.get("/") | ||||||
|  | async def index(request: Request): | ||||||
|  |     return FileResponse("static/dist/index.html", media_type="text/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, events: List[Event]): | ||||||
|  |         await self.websocket.send_json([event.to_dict() for event in events]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @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) | ||||||
|  |     logger.info(f"Creating {len(session_entities)} entities for session {session}") | ||||||
|  |     try: | ||||||
|  |         network_system = system_manager.get_system(SystemType.NETWORK) | ||||||
|  |         if network_system is None: | ||||||
|  |             raise "Network system not found" | ||||||
|  |         network_system.add_client(session, WebSocketClient(websocket)) | ||||||
|  | 
 | ||||||
|  |         network_system.client_downstream_event( | ||||||
|  |             session, | ||||||
|  |             InitialStateEvent( | ||||||
|  |                 config.WORLD_WIDTH, config.WORLD_HEIGHT, kennel.entity_manager.to_dict() | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         for entity in session_entities: | ||||||
|  |             logger.info(f"Adding entity {entity.id} for session {session}") | ||||||
|  |             entity_manager.add_entity(entity) | ||||||
|  | 
 | ||||||
|  |             network_system.server_global_event(EntityBornEvent(entity)) | ||||||
|  |             network_system.client_downstream_event( | ||||||
|  |                 session, SetControllableEvent(entity.id, session) | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         while True: | ||||||
|  |             message = await websocket.receive_json() | ||||||
|  |             if not isinstance(message, list): | ||||||
|  |                 message = [message] | ||||||
|  |             events = [Event.from_dict(event) for event in message] | ||||||
|  |             if not all([event is not None for event in events]): | ||||||
|  |                 logger.info(f"Invalid events in: {message}"[:100]) | ||||||
|  |                 continue | ||||||
|  |             for event in events: | ||||||
|  |                 network_system.client_upstream_event(session, event) | ||||||
|  |     except WebSocketDisconnect: | ||||||
|  |         logger.info(f"Websocket connection closed by client: {session}") | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error("Exception occurred", exc_info=e) | ||||||
|  |     finally: | ||||||
|  |         network_system.remove_client(session) | ||||||
|  |         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})) | ||||||
|  | 
 | ||||||
|  |         await websocket.close() | ||||||
|  |         logger.info("Websocket connection closed") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @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,33 @@ | ||||||
|  | import uuid | ||||||
|  | 
 | ||||||
|  | import structlog | ||||||
|  | from fastapi import Request | ||||||
|  | 
 | ||||||
|  | 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
											
										
									
								
							|  | @ -11,13 +11,13 @@ python = "^3.12" | ||||||
| fastapi = "^0.108.0" | 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" | 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" | ||||||
| httpx = "^0.26.0" | httpx = "^0.26.0" | ||||||
| pytest = "^7.4.3" |  | ||||||
| pytest-cov = "^4.1.0" |  | ||||||
| 
 | 
 | ||||||
| [build-system] | [build-system] | ||||||
| requires = ["poetry-core"] | requires = ["poetry-core"] | ||||||
|  |  | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | # Logs | ||||||
|  | logs | ||||||
|  | *.log | ||||||
|  | npm-debug.log* | ||||||
|  | yarn-debug.log* | ||||||
|  | yarn-error.log* | ||||||
|  | pnpm-debug.log* | ||||||
|  | lerna-debug.log* | ||||||
|  | 
 | ||||||
|  | node_modules | ||||||
|  | dist | ||||||
|  | dist-ssr | ||||||
|  | *.local | ||||||
|  | 
 | ||||||
|  | # Editor directories and files | ||||||
|  | .vscode/* | ||||||
|  | !.vscode/extensions.json | ||||||
|  | .idea | ||||||
|  | .DS_Store | ||||||
|  | *.suo | ||||||
|  | *.ntvs* | ||||||
|  | *.njsproj | ||||||
|  | *.sln | ||||||
|  | *.sw? | ||||||
|  | @ -0,0 +1,22 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |   <meta charset="UTF-8"> | ||||||
|  |   <link rel="icon" href= | ||||||
|  |   "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐶</text></svg>"> | ||||||
|  |   <meta name="viewport" content= | ||||||
|  |   "width=device-width, initial-scale=1.0"> | ||||||
|  |   <title>the kennel.</title> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |   <noscript> | ||||||
|  |     <div style="text-align: center"> | ||||||
|  |       <h1>yeah, unfortunately you need javascript ;3</h1> | ||||||
|  |     </div> | ||||||
|  |   </noscript> | ||||||
|  |   <div id="app"> | ||||||
|  |     <canvas id="gamecanvas"></canvas> | ||||||
|  |   </div> | ||||||
|  |   <script type="module" src="/src/main.ts"></script> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | @ -1,5 +0,0 @@ | ||||||
| window.onload = () => { |  | ||||||
|     console.log('from js'); |  | ||||||
|     const kennelWindowEle = document.querySelector('#kennel-window'); |  | ||||||
|     kennelWindowEle.innerHTML = 'rendered from static/index.js'; |  | ||||||
| } |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | { | ||||||
|  |   "name": "kennel", | ||||||
|  |   "private": true, | ||||||
|  |   "version": "0.1.0", | ||||||
|  |   "type": "module", | ||||||
|  |   "scripts": { | ||||||
|  |     "build": "tsc && vite build", | ||||||
|  |     "preview": "vite preview", | ||||||
|  |     "dev": "./node_modules/nodemon/bin/nodemon.js --watch './src/**/*' -e ts,html --exec \"npm run build\"" | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "@rollup/plugin-inject": "^5.0.5", | ||||||
|  |     "@types/jquery": "^3.5.30", | ||||||
|  |     "nodemon": "^3.1.4", | ||||||
|  |     "typescript": "^5.5.3", | ||||||
|  |     "vite": "^5.4.1", | ||||||
|  |     "vite-plugin-dynamic-base": "^1.1.0" | ||||||
|  |   }, | ||||||
|  |   "dependencies": { | ||||||
|  |     "jquery": "^3.7.1", | ||||||
|  |     "laser-pen": "^1.0.1" | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | ||||||
| After Width: | Height: | Size: 1.5 KiB | 
|  | @ -0,0 +1,46 @@ | ||||||
|  | export enum ComponentType { | ||||||
|  |   POSITION = "POSITION", | ||||||
|  |   RENDERABLE = "RENDERABLE", | ||||||
|  |   TRAILING_POSITION = "TRAILING_POSITION", | ||||||
|  |   SPRITESHEET = "SPRITESHEET", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface Component { | ||||||
|  |   name: ComponentType; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface PositionComponent extends Component { | ||||||
|  |   name: ComponentType.POSITION; | ||||||
|  |   x: number; | ||||||
|  |   y: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface TrailingPositionComponent extends Component { | ||||||
|  |   name: ComponentType.TRAILING_POSITION; | ||||||
|  |   trails: Array<{ x: number; y: number; time: number }>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface RenderableComponent extends Component { | ||||||
|  |   name: ComponentType.RENDERABLE; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface SpriteSpec { | ||||||
|  |   ms_per_frame: number; | ||||||
|  |   frames: number; | ||||||
|  |   top_x: number; | ||||||
|  |   top_y: number; | ||||||
|  |   end_x: number; | ||||||
|  |   end_y: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface SpriteSheetComponent extends Component { | ||||||
|  |   name: ComponentType.SPRITESHEET; | ||||||
|  |   source: string; | ||||||
|  |   sheet?: HTMLImageElement; | ||||||
|  | 
 | ||||||
|  |   current_frame: number; | ||||||
|  |   last_update: number; | ||||||
|  |   current_state: string; | ||||||
|  | 
 | ||||||
|  |   state_to_spritespec: Record<string, SpriteSpec>; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,46 @@ | ||||||
|  | export class DebouncePublisher<T> { | ||||||
|  |   private last_event_time = Date.now(); | ||||||
|  |   private unpublished_data: T | undefined; | ||||||
|  |   private interval_id: number | undefined; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private readonly publisher: (data: T) => void | Promise<void>, | ||||||
|  |     private readonly debounce_ms = 100, | ||||||
|  |   ) {} | ||||||
|  | 
 | ||||||
|  |   public start() { | ||||||
|  |     if (typeof this.interval_id !== "undefined") { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     this.interval_id = setInterval( | ||||||
|  |       () => this.debounce_publish(), | ||||||
|  |       this.debounce_ms, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public stop() { | ||||||
|  |     if (this.interval_id === null) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     clearInterval(this.interval_id); | ||||||
|  |     delete this.interval_id; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public update(data: T) { | ||||||
|  |     this.unpublished_data = data; | ||||||
|  |     this.debounce_publish(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private debounce_publish() { | ||||||
|  |     if ( | ||||||
|  |       Date.now() - this.last_event_time < this.debounce_ms || | ||||||
|  |       typeof this.unpublished_data === "undefined" | ||||||
|  |     ) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.last_event_time = Date.now(); | ||||||
|  |     this.publisher(this.unpublished_data); | ||||||
|  |     this.unpublished_data = undefined; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | import { | ||||||
|  |   Component, | ||||||
|  |   ComponentType, | ||||||
|  |   RenderableComponent, | ||||||
|  |   TrailingPositionComponent, | ||||||
|  | } from "./component"; | ||||||
|  | 
 | ||||||
|  | export enum EntityType { | ||||||
|  |   LASER = "LASER", | ||||||
|  |   CAT = "CAT", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface Entity { | ||||||
|  |   entity_type: EntityType; | ||||||
|  |   id: string; | ||||||
|  |   components: Record<ComponentType, Component>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const create_laser = (base: Entity) => { | ||||||
|  |   const trailing_position: TrailingPositionComponent = { | ||||||
|  |     name: ComponentType.TRAILING_POSITION, | ||||||
|  |     trails: [], | ||||||
|  |   }; | ||||||
|  |   base.components[ComponentType.TRAILING_POSITION] = trailing_position; | ||||||
|  | 
 | ||||||
|  |   const renderable: RenderableComponent = { | ||||||
|  |     name: ComponentType.RENDERABLE, | ||||||
|  |   }; | ||||||
|  |   base.components[ComponentType.RENDERABLE] = renderable; | ||||||
|  |   return base; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const create_cat = (base: Entity) => { | ||||||
|  |   const renderable: RenderableComponent = { | ||||||
|  |     name: ComponentType.RENDERABLE, | ||||||
|  |   }; | ||||||
|  |   base.components[ComponentType.RENDERABLE] = renderable; | ||||||
|  |   return base; | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,107 @@ | ||||||
|  | import { Entity } from "./entity"; | ||||||
|  | export enum EventType { | ||||||
|  |   INITIAL_STATE = "INITIAL_STATE", | ||||||
|  |   SET_CONTROLLABLE = "SET_CONTROLLABLE", | ||||||
|  |   ENTITY_BORN = "ENTITY_BORN", | ||||||
|  |   ENTITY_DEATH = "ENTITY_DEATH", | ||||||
|  |   ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface Event { | ||||||
|  |   event_type: EventType; | ||||||
|  |   data: any; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface EntityPositionUpdateEvent extends Event { | ||||||
|  |   event_type: EventType.ENTITY_POSITION_UPDATE; | ||||||
|  |   data: { | ||||||
|  |     id: string; | ||||||
|  |     position: { | ||||||
|  |       x: number; | ||||||
|  |       y: number; | ||||||
|  |     }; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface InitialStateEvent extends Event { | ||||||
|  |   event_type: EventType.INITIAL_STATE; | ||||||
|  |   data: { | ||||||
|  |     world: { width: number; height: number }; | ||||||
|  |     entities: Record<string, Entity>; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface SetControllableEvent extends Event { | ||||||
|  |   event_type: EventType.SET_CONTROLLABLE; | ||||||
|  |   data: { | ||||||
|  |     id: string; | ||||||
|  |     client_id: string; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface EntityBornEvent extends Event { | ||||||
|  |   event_type: EventType.ENTITY_BORN; | ||||||
|  |   data: { | ||||||
|  |     entity: Entity; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface EntityDeathEvent extends Event { | ||||||
|  |   event_type: EventType.ENTITY_DEATH; | ||||||
|  |   data: { | ||||||
|  |     id: string; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface EventQueue { | ||||||
|  |   peek(): Event[]; | ||||||
|  |   clear(): void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface EventPublisher { | ||||||
|  |   add(event: Event): void; | ||||||
|  |   publish(): void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class WebSocketEventQueue implements EventQueue { | ||||||
|  |   private queue: Event[]; | ||||||
|  | 
 | ||||||
|  |   constructor(websocket: WebSocket) { | ||||||
|  |     this.queue = []; | ||||||
|  |     this.listen_to(websocket); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public peek() { | ||||||
|  |     return this.queue; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public clear() { | ||||||
|  |     this.queue = []; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private listen_to(websocket: WebSocket) { | ||||||
|  |     websocket.onmessage = ({ data }) => { | ||||||
|  |       this.queue = this.queue.concat(JSON.parse(data)); | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class WebsocketEventPublisher implements EventPublisher { | ||||||
|  |   private queue: Event[]; | ||||||
|  | 
 | ||||||
|  |   constructor(private readonly websocket: WebSocket) { | ||||||
|  |     this.queue = []; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public add(event: Event) { | ||||||
|  |     this.queue.push(event); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public publish() { | ||||||
|  |     if (this.queue.length === 0) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     this.websocket.send(JSON.stringify(this.queue)); | ||||||
|  |     this.queue = []; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,112 @@ | ||||||
|  | import { System, SystemType } from "./system"; | ||||||
|  | import { Entity } from "./entity"; | ||||||
|  | import { ComponentType } from "./component"; | ||||||
|  | 
 | ||||||
|  | export class Game { | ||||||
|  |   private running: boolean; | ||||||
|  |   private last_update: number; | ||||||
|  | 
 | ||||||
|  |   private readonly entities: Map<string, Entity> = new Map(); | ||||||
|  |   private readonly component_entities: Map<ComponentType, Set<string>> = | ||||||
|  |     new Map(); | ||||||
|  |   private readonly systems: Map<SystemType, System> = new Map(); | ||||||
|  |   private readonly system_order: SystemType[]; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     public readonly client_id: string, | ||||||
|  |     systems: System[], | ||||||
|  |   ) { | ||||||
|  |     this.last_update = performance.now(); | ||||||
|  |     this.running = false; | ||||||
|  | 
 | ||||||
|  |     systems.forEach((system) => this.systems.set(system.system_type, system)); | ||||||
|  |     this.system_order = systems.map(({ system_type }) => system_type); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public start() { | ||||||
|  |     if (this.running) return; | ||||||
|  | 
 | ||||||
|  |     console.log("starting game"); | ||||||
|  |     this.running = true; | ||||||
|  |     this.last_update = performance.now(); | ||||||
|  | 
 | ||||||
|  |     const game_loop = (timestamp: number) => { | ||||||
|  |       if (!this.running) return; | ||||||
|  | 
 | ||||||
|  |       // rebuild component -> { entity } map
 | ||||||
|  |       this.component_entities.clear(); | ||||||
|  |       Array.from(this.entities.values()).forEach((entity) => | ||||||
|  |         Object.values(entity.components).forEach((component) => { | ||||||
|  |           const set = | ||||||
|  |             this.component_entities.get(component.name) ?? new Set<string>(); | ||||||
|  |           set.add(entity.id); | ||||||
|  |           this.component_entities.set(component.name, set); | ||||||
|  |         }), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       const dt = timestamp - this.last_update; | ||||||
|  | 
 | ||||||
|  |       this.system_order.forEach((system_type) => | ||||||
|  |         this.systems.get(system_type)!.update(dt, this), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       this.last_update = timestamp; | ||||||
|  |       requestAnimationFrame(game_loop); // tail call recursion! /s
 | ||||||
|  |     }; | ||||||
|  |     requestAnimationFrame(game_loop); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public stop() { | ||||||
|  |     if (!this.running) return; | ||||||
|  |     this.running = false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public for_each_entity_with_component( | ||||||
|  |     component: ComponentType, | ||||||
|  |     callback: (entity: Entity) => void, | ||||||
|  |   ) { | ||||||
|  |     this.component_entities.get(component)?.forEach((entity_id) => { | ||||||
|  |       const entity = this.entities.get(entity_id); | ||||||
|  |       if (!entity) return; | ||||||
|  | 
 | ||||||
|  |       callback(entity); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public get_entity(id: string) { | ||||||
|  |     return this.entities.get(id); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public put_entity(entity: Entity) { | ||||||
|  |     const old_entity = this.entities.get(entity.id); | ||||||
|  |     if (old_entity) this.clear_entity_components(old_entity); | ||||||
|  | 
 | ||||||
|  |     Object.values(entity.components).forEach((component) => { | ||||||
|  |       const set = | ||||||
|  |         this.component_entities.get(component.name) ?? new Set<string>(); | ||||||
|  |       set.add(entity.id); | ||||||
|  |       this.component_entities.set(component.name, set); | ||||||
|  |     }); | ||||||
|  |     this.entities.set(entity.id, entity); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public remove_entity(id: string) { | ||||||
|  |     const entity = this.entities.get(id); | ||||||
|  |     if (typeof entity === "undefined") return; | ||||||
|  | 
 | ||||||
|  |     this.clear_entity_components(entity); | ||||||
|  |     this.entities.delete(id); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private clear_entity_components(entity: Entity) { | ||||||
|  |     Object.values(entity.components).forEach((component) => { | ||||||
|  |       const set = this.component_entities.get(component.name); | ||||||
|  |       if (typeof set === "undefined") return; | ||||||
|  |       set.delete(entity.id); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public get_system<T extends System>(system_type: SystemType): T | undefined { | ||||||
|  |     return this.systems.get(system_type) as T; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,49 @@ | ||||||
|  | import { DebouncePublisher } from "./debounce_publisher"; | ||||||
|  | import { EntityPositionUpdateEvent, EventPublisher, EventType } from "./events"; | ||||||
|  | import { Game } from "./game"; | ||||||
|  | import { System, SystemType } from "./system"; | ||||||
|  | 
 | ||||||
|  | export class InputSystem extends System { | ||||||
|  |   private readonly controllable_entities: Set<string> = new Set(); | ||||||
|  |   private readonly mouse_movement_debouncer: DebouncePublisher<{ | ||||||
|  |     x: number; | ||||||
|  |     y: number; | ||||||
|  |   }>; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private readonly message_publisher: EventPublisher, | ||||||
|  |     target: HTMLElement, | ||||||
|  |   ) { | ||||||
|  |     super(SystemType.INPUT); | ||||||
|  | 
 | ||||||
|  |     this.mouse_movement_debouncer = new DebouncePublisher((data) => | ||||||
|  |       this.publish_mouse_movement(data), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     target.addEventListener("mousemove", (event) => { | ||||||
|  |       this.mouse_movement_debouncer.update({ | ||||||
|  |         x: event.clientX, | ||||||
|  |         y: event.clientY, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private publish_mouse_movement({ x, y }: { x: number; y: number }) { | ||||||
|  |     console.log(`publishing mouse movement at (${x}, ${y})`); | ||||||
|  |     for (const entity_id of this.controllable_entities) { | ||||||
|  |       this.message_publisher.add({ | ||||||
|  |         event_type: EventType.ENTITY_POSITION_UPDATE, | ||||||
|  |         data: { | ||||||
|  |           id: entity_id, | ||||||
|  |           position: { x, y }, | ||||||
|  |         }, | ||||||
|  |       } as EntityPositionUpdateEvent); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public add_controllable_entity(entity_id: string) { | ||||||
|  |     this.controllable_entities.add(entity_id); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public update(_dt: number, _game: Game) {} | ||||||
|  | } | ||||||
|  | @ -0,0 +1,138 @@ | ||||||
|  | import { | ||||||
|  |   ComponentType, | ||||||
|  |   PositionComponent, | ||||||
|  |   TrailingPositionComponent, | ||||||
|  | } from "./component"; | ||||||
|  | import { create_cat, create_laser, Entity, EntityType } from "./entity"; | ||||||
|  | import { | ||||||
|  |   EntityBornEvent, | ||||||
|  |   EntityDeathEvent, | ||||||
|  |   EntityPositionUpdateEvent, | ||||||
|  |   EventPublisher, | ||||||
|  |   EventQueue, | ||||||
|  |   EventType, | ||||||
|  |   InitialStateEvent, | ||||||
|  |   SetControllableEvent, | ||||||
|  | } from "./events"; | ||||||
|  | import { Game } from "./game"; | ||||||
|  | import { InputSystem } from "./input"; | ||||||
|  | import { RenderSystem } from "./render"; | ||||||
|  | import { System, SystemType } from "./system"; | ||||||
|  | 
 | ||||||
|  | export class NetworkSystem extends System { | ||||||
|  |   constructor( | ||||||
|  |     private readonly event_queue: EventQueue, | ||||||
|  |     private readonly event_publisher: EventPublisher, | ||||||
|  |   ) { | ||||||
|  |     super(SystemType.NETWORK); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public update(_dt: number, game: Game) { | ||||||
|  |     const events = this.event_queue.peek(); | ||||||
|  |     for (const event of events) { | ||||||
|  |       switch (event.event_type) { | ||||||
|  |         case EventType.INITIAL_STATE: | ||||||
|  |           this.process_initial_state_event(event as InitialStateEvent, game); | ||||||
|  |           break; | ||||||
|  |         case EventType.SET_CONTROLLABLE: | ||||||
|  |           this.process_set_controllable_event( | ||||||
|  |             event as SetControllableEvent, | ||||||
|  |             game, | ||||||
|  |           ); | ||||||
|  |           break; | ||||||
|  |         case EventType.ENTITY_BORN: | ||||||
|  |           this.process_entity_born_event(event as EntityBornEvent, game); | ||||||
|  |           break; | ||||||
|  |         case EventType.ENTITY_POSITION_UPDATE: | ||||||
|  |           this.process_entity_position_update_event( | ||||||
|  |             event as EntityPositionUpdateEvent, | ||||||
|  |             game, | ||||||
|  |           ); | ||||||
|  |           break; | ||||||
|  |         case EventType.ENTITY_DEATH: | ||||||
|  |           this.process_entity_death_event(event as EntityDeathEvent, game); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.event_queue.clear(); | ||||||
|  |     this.event_publisher.publish(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_initial_state_event(event: InitialStateEvent, game: Game) { | ||||||
|  |     console.log("received initial state", event); | ||||||
|  |     const { world, entities } = event.data; | ||||||
|  |     const render_system = game.get_system<RenderSystem>(SystemType.RENDER); | ||||||
|  |     if (!render_system) { | ||||||
|  |       console.error("render system not found"); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     render_system.set_world_dimensions(world.width, world.height); | ||||||
|  |     Object.values(entities).forEach((entity) => | ||||||
|  |       game.put_entity(this.process_new_entity(entity)), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_entity_position_update_event( | ||||||
|  |     event: EntityPositionUpdateEvent, | ||||||
|  |     game: Game, | ||||||
|  |   ) { | ||||||
|  |     console.log("received entity position update", event); | ||||||
|  |     const { position, id } = event.data; | ||||||
|  |     const entity = game.get_entity(id); | ||||||
|  |     if (typeof entity === "undefined") return; | ||||||
|  | 
 | ||||||
|  |     const position_component = entity.components[ | ||||||
|  |       ComponentType.POSITION | ||||||
|  |     ] as PositionComponent; | ||||||
|  |     position_component.x = position.x; | ||||||
|  |     position_component.y = position.y; | ||||||
|  | 
 | ||||||
|  |     if (ComponentType.TRAILING_POSITION in entity.components) { | ||||||
|  |       const trailing_position = entity.components[ | ||||||
|  |         ComponentType.TRAILING_POSITION | ||||||
|  |       ] as TrailingPositionComponent; | ||||||
|  |       trailing_position.trails.push({ | ||||||
|  |         x: position_component.x, | ||||||
|  |         y: position_component.y, | ||||||
|  |         time: Date.now(), | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_entity_born_event(event: EntityBornEvent, game: Game) { | ||||||
|  |     console.log("received a new entity", event); | ||||||
|  |     const { entity } = event.data; | ||||||
|  |     game.put_entity(this.process_new_entity(entity)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_new_entity(entity: Entity): Entity { | ||||||
|  |     if (entity.entity_type === EntityType.LASER) { | ||||||
|  |       return create_laser(entity); | ||||||
|  |     } | ||||||
|  |     if (entity.entity_type === EntityType.CAT) { | ||||||
|  |       return create_cat(entity); | ||||||
|  |     } | ||||||
|  |     return entity; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_entity_death_event(event: EntityDeathEvent, game: Game) { | ||||||
|  |     console.log("an entity died D:", event); | ||||||
|  |     const { id } = event.data; | ||||||
|  |     game.remove_entity(id); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private process_set_controllable_event( | ||||||
|  |     event: SetControllableEvent, | ||||||
|  |     game: Game, | ||||||
|  |   ) { | ||||||
|  |     console.log("got a controllable event", event); | ||||||
|  |     if (event.data.client_id !== game.client_id) { | ||||||
|  |       console.warn("got controllable event for client that is not us"); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const input_system = game.get_system<InputSystem>(SystemType.INPUT)!; | ||||||
|  |     input_system.add_controllable_entity(event.data.id); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,120 @@ | ||||||
|  | import { | ||||||
|  |   ComponentType, | ||||||
|  |   PositionComponent, | ||||||
|  |   TrailingPositionComponent, | ||||||
|  |   SpriteSheetComponent, | ||||||
|  |   SpriteSpec, | ||||||
|  | } from "./component"; | ||||||
|  | import { Game } from "./game"; | ||||||
|  | import { System, SystemType } from "./system"; | ||||||
|  | import { drawLaserPen } from "laser-pen"; | ||||||
|  | 
 | ||||||
|  | export class RenderSystem extends System { | ||||||
|  |   constructor(private readonly canvas: HTMLCanvasElement) { | ||||||
|  |     super(SystemType.RENDER); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public set_world_dimensions(width: number, height: number) { | ||||||
|  |     this.canvas.width = width; | ||||||
|  |     this.canvas.height = height; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public update(dt: number, game: Game) { | ||||||
|  |     const ctx = this.canvas.getContext("2d"); | ||||||
|  |     if (ctx === null) return; | ||||||
|  |     ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | ||||||
|  | 
 | ||||||
|  |     game.for_each_entity_with_component(ComponentType.RENDERABLE, (entity) => { | ||||||
|  |       if (ComponentType.TRAILING_POSITION in entity.components) { | ||||||
|  |         const trailing_position = entity.components[ | ||||||
|  |           ComponentType.TRAILING_POSITION | ||||||
|  |         ] as TrailingPositionComponent; | ||||||
|  |         if (trailing_position.trails.length < 3) return; | ||||||
|  |         drawLaserPen(ctx, trailing_position.trails); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if ( | ||||||
|  |         ComponentType.POSITION in entity.components && | ||||||
|  |         ComponentType.SPRITESHEET in entity.components | ||||||
|  |       ) { | ||||||
|  |         const position = entity.components[ | ||||||
|  |           ComponentType.POSITION | ||||||
|  |         ] as PositionComponent; | ||||||
|  |         const spritesheet = entity.components[ | ||||||
|  |           ComponentType.SPRITESHEET | ||||||
|  |         ] as SpriteSheetComponent; | ||||||
|  | 
 | ||||||
|  |         if (typeof spritesheet.sheet === "undefined") { | ||||||
|  |           const img = new Image(); | ||||||
|  |           img.src = spritesheet.source; | ||||||
|  |           spritesheet.sheet = img; | ||||||
|  |         } | ||||||
|  |         const spritespec = | ||||||
|  |           spritesheet.state_to_spritespec[spritesheet.current_state]; | ||||||
|  | 
 | ||||||
|  |         this.blit_sprite(ctx, spritesheet, position); | ||||||
|  | 
 | ||||||
|  |         spritesheet.last_update += dt; | ||||||
|  |         if (spritesheet.last_update > spritespec.ms_per_frame) { | ||||||
|  |           spritesheet.current_frame++; | ||||||
|  |           spritesheet.current_frame %= spritespec.frames; | ||||||
|  |           spritesheet.last_update = 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private blit_sprite( | ||||||
|  |     ctx: CanvasRenderingContext2D, | ||||||
|  |     spritesheet: SpriteSheetComponent, | ||||||
|  |     position: { x: number; y: number }, | ||||||
|  |   ) { | ||||||
|  |     ctx.save(); | ||||||
|  |     ctx.translate(position.x, position.y); | ||||||
|  |     ctx.translate(-position.x, -position.y); | ||||||
|  | 
 | ||||||
|  |     if (typeof spritesheet.sheet === "undefined") return; | ||||||
|  | 
 | ||||||
|  |     const spritespec = | ||||||
|  |       spritesheet.state_to_spritespec[spritesheet.current_state]; | ||||||
|  | 
 | ||||||
|  |     ctx.drawImage( | ||||||
|  |       spritesheet.sheet, | ||||||
|  |       ...this.get_sprite_args(spritesheet.current_frame, spritespec), | ||||||
|  |       ...this.get_draw_args(spritespec, position), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     ctx.restore(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private get_sprite_args( | ||||||
|  |     current_frame: number, | ||||||
|  |     sprite_spec: SpriteSpec, | ||||||
|  |   ): [sx: number, sy: number, sw: number, sh: number] { | ||||||
|  |     const [width, height] = this.get_dimensions(sprite_spec); | ||||||
|  |     return [ | ||||||
|  |       sprite_spec.top_x, | ||||||
|  |       sprite_spec.top_y + current_frame * height, | ||||||
|  |       width, | ||||||
|  |       height, | ||||||
|  |     ]; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private get_draw_args( | ||||||
|  |     sprite_spec: SpriteSpec, | ||||||
|  |     position: { x: number; y: number }, | ||||||
|  |   ): [dx: number, dy: number, dw: number, dh: number] { | ||||||
|  |     const [width, height] = this.get_dimensions(sprite_spec); | ||||||
|  |     return [position.x - width / 2, position.y - height / 2, width, height]; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private get_dimensions(sprite_spec: SpriteSpec): [number, number] { | ||||||
|  |     return [ | ||||||
|  |       sprite_spec.end_x - sprite_spec.top_x, | ||||||
|  |       (sprite_spec.end_y - sprite_spec.top_y) / sprite_spec.frames, | ||||||
|  |     ]; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,14 @@ | ||||||
|  | import { Game } from "./game"; | ||||||
|  | 
 | ||||||
|  | export enum SystemType { | ||||||
|  |   INPUT = "INPUT", | ||||||
|  |   NETWORK = "NETWORK", | ||||||
|  |   RENDER = "RENDER", | ||||||
|  |   TRAILING_POSITION = "TRAILING_POSITION", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export abstract class System { | ||||||
|  |   constructor(public readonly system_type: SystemType) {} | ||||||
|  | 
 | ||||||
|  |   abstract update(dt: number, game: Game): void; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,29 @@ | ||||||
|  | import { ComponentType, TrailingPositionComponent } from "./component"; | ||||||
|  | import { Game } from "./game"; | ||||||
|  | import { System, SystemType } from "./system"; | ||||||
|  | 
 | ||||||
|  | interface Point { | ||||||
|  |   x: number; | ||||||
|  |   y: number; | ||||||
|  |   time: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class TrailingPositionSystem extends System { | ||||||
|  |   constructor( | ||||||
|  |     private readonly point_filter: (trail_point: Array<Point>) => Array<Point> | ||||||
|  |   ) { | ||||||
|  |     super(SystemType.TRAILING_POSITION); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public update(_dt: number, game: Game) { | ||||||
|  |     game.for_each_entity_with_component( | ||||||
|  |       ComponentType.TRAILING_POSITION, | ||||||
|  |       (entity) => { | ||||||
|  |         const trailing_position = entity.components[ | ||||||
|  |           ComponentType.TRAILING_POSITION | ||||||
|  |         ] as TrailingPositionComponent; | ||||||
|  |         trailing_position.trails = this.point_filter(trailing_position.trails); | ||||||
|  |       } | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,51 @@ | ||||||
|  | import $ from "jquery"; | ||||||
|  | import { | ||||||
|  |   EventPublisher, | ||||||
|  |   EventQueue, | ||||||
|  |   WebsocketEventPublisher, | ||||||
|  |   WebSocketEventQueue, | ||||||
|  | } from "./engine/events"; | ||||||
|  | import { Game } from "./engine/game"; | ||||||
|  | import { NetworkSystem } from "./engine/network"; | ||||||
|  | import { RenderSystem } from "./engine/render"; | ||||||
|  | import { InputSystem } from "./engine/input"; | ||||||
|  | import { TrailingPositionSystem } from "./engine/trailing_position"; | ||||||
|  | import { drainPoints, setDelay, setRoundCap } from "laser-pen"; | ||||||
|  | 
 | ||||||
|  | $(async () => { | ||||||
|  |   const client_id = await fetch("/assign", { | ||||||
|  |     credentials: "include", | ||||||
|  |   }) | ||||||
|  |     .then((res) => res.json()) | ||||||
|  |     .then(({ session }) => session); | ||||||
|  | 
 | ||||||
|  |   const ws = new WebSocket("/ws"); | ||||||
|  |   await new Promise<void>((resolve) => { | ||||||
|  |     ws.onopen = () => resolve(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   const queue: EventQueue = new WebSocketEventQueue(ws); | ||||||
|  |   const publisher: EventPublisher = new WebsocketEventPublisher(ws); | ||||||
|  |   const network_system = new NetworkSystem(queue, publisher); | ||||||
|  | 
 | ||||||
|  |   const gamecanvas = $("#gamecanvas").get(0)! as HTMLCanvasElement; | ||||||
|  |   const input_system = new InputSystem(publisher, gamecanvas); | ||||||
|  | 
 | ||||||
|  |   setDelay(500); | ||||||
|  |   setRoundCap(true); | ||||||
|  |   const render_system = new RenderSystem(gamecanvas); | ||||||
|  | 
 | ||||||
|  |   const trailing_position = new TrailingPositionSystem(drainPoints); | ||||||
|  | 
 | ||||||
|  |   const systems = [ | ||||||
|  |     network_system, | ||||||
|  |     trailing_position, | ||||||
|  |     input_system, | ||||||
|  |     render_system, | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
|  |   const game = new Game(client_id, systems); | ||||||
|  |   ws.onclose = () => game.stop(); | ||||||
|  | 
 | ||||||
|  |   game.start(); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,3 @@ | ||||||
|  | h1 { | ||||||
|  |   color: red; | ||||||
|  | } | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | /// <reference types="vite/client" />
 | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | { | ||||||
|  |   "compilerOptions": { | ||||||
|  |     "target": "ES2022", | ||||||
|  |     "useDefineForClassFields": true, | ||||||
|  |     "module": "ESNext", | ||||||
|  |     "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||||||
|  |     "skipLibCheck": true, | ||||||
|  | 
 | ||||||
|  |     /* Bundler mode */ | ||||||
|  |     "moduleResolution": "bundler", | ||||||
|  |     "allowImportingTsExtensions": true, | ||||||
|  |     "isolatedModules": true, | ||||||
|  |     "moduleDetection": "force", | ||||||
|  |     "noEmit": true, | ||||||
|  | 
 | ||||||
|  |     /* Linting */ | ||||||
|  |     "strict": true, | ||||||
|  |     "noUnusedLocals": true, | ||||||
|  |     "noUnusedParameters": true, | ||||||
|  |     "noFallthroughCasesInSwitch": true | ||||||
|  |   }, | ||||||
|  |   "include": ["src"] | ||||||
|  | } | ||||||
|  | @ -0,0 +1,27 @@ | ||||||
|  | import { fileURLToPath, URL } from "node:url"; | ||||||
|  | import { dynamicBase } from "vite-plugin-dynamic-base"; | ||||||
|  | import { defineConfig } from "vite"; | ||||||
|  | import inject from "@rollup/plugin-inject"; | ||||||
|  | 
 | ||||||
|  | // https://vitejs.dev/config/
 | ||||||
|  | export default defineConfig({ | ||||||
|  |   plugins: [ | ||||||
|  |     dynamicBase({ | ||||||
|  |       // dynamic public path var string, default window.__dynamic_base__
 | ||||||
|  |       publicPath: "", | ||||||
|  |       // dynamic load resources on index.html, default false. maybe change default true
 | ||||||
|  |       transformIndexHtml: false, | ||||||
|  |     }), | ||||||
|  |     inject({ | ||||||
|  |       // => that should be first under plugins array
 | ||||||
|  |       $: "jquery", | ||||||
|  |       jQuery: "jquery", | ||||||
|  |     }), | ||||||
|  |   ], | ||||||
|  |   resolve: { | ||||||
|  |     alias: { | ||||||
|  |       "@": fileURLToPath(new URL("./src", import.meta.url)), | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   base: "/static/", | ||||||
|  | }); | ||||||
|  | @ -1,14 +0,0 @@ | ||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
|   <head> |  | ||||||
|     <meta charset="UTF-8"> |  | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |  | ||||||
|     <meta http-equiv="X-UA-Compatible" content="ie=edge"> |  | ||||||
|     <title>Kennel Club</title> |  | ||||||
|   </head> |  | ||||||
|   <body> |  | ||||||
|     <div id="kennel-window"></div> |  | ||||||
|     <script src="{{ url_for('static', path='/index.js') }}"></script> |  | ||||||
|   </body> |  | ||||||
| </html> |  | ||||||
| 
 |  | ||||||
|  | @ -1,18 +0,0 @@ | ||||||
| from http import HTTPStatus |  | ||||||
| 
 |  | ||||||
| from fastapi.testclient import TestClient |  | ||||||
| from kennel.main import app |  | ||||||
| from structlog.testing import capture_logs |  | ||||||
| 
 |  | ||||||
| client = TestClient(app) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_healthcheck(): |  | ||||||
|     response = client.get("/healthcheck") |  | ||||||
|     assert response.status_code == HTTPStatus.OK |  | ||||||
|     assert response.text == "hello" |  | ||||||
| 
 |  | ||||||
| def test_main(): |  | ||||||
|     response = client.get("/") |  | ||||||
|     assert response.status_code == HTTPStatus.OK |  | ||||||
|     assert response.text.startswith("<!DOCTYPE html>") |  | ||||||
		Loading…
	
		Reference in New Issue