WIP: ECS / Network System #1

Draft
simponic wants to merge 13 commits from websockets into main
23 changed files with 1173 additions and 436 deletions
Showing only changes of commit b414046001 - Show all commits

View File

@ -1,2 +0,0 @@
*.sqlite
*.db

13
kennel/app.py Normal file
View File

@ -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")

20
kennel/config.py Normal file
View File

@ -0,0 +1,20 @@
from dotenv import load_dotenv
import os
load_dotenv()
class Config:
SIMULATION_WIDTH = int(os.getenv("SIMULATION_WIDTH", 1000))
SIMULATION_HEIGHT = int(os.getenv("SIMULATION_HEIGHT", 1000))
HATECOMPUTERS_ENDPOINT = os.getenv(
"HATECOMPUTERS_ENDPOINT", "https://hatecomputers.club"
)
MIN_TIME_STEP = float(os.getenv("MIN_TIME_STEP", 0.05))
COOKIE_MAX_AGE = int(os.getenv("COOKIE_MAX_AGE", 60 * 60 * 24 * 7)) # 1 week
config = Config()

View File

@ -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

View File

@ -0,0 +1,13 @@
from .component import Component, ComponentType
class Controllable(Component):
def __init__(self, by: str):
super().__init__(ComponentType.CONTROLLABLE)
self.by = by
def __repr__(self) -> str:
return f"Controllable(by={self.by})"
def dict(self) -> dict:
return {"by": self.by}

View File

@ -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}

View File

View File

@ -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
#
#

View File

@ -0,0 +1,60 @@
from enum import Enum
from kennel.engine.components.component import Component, ComponentType
from typing import List, Optional
class EntityType(str, Enum):
LASER = "LASER"
class Entity:
def __init__(
self, entity_type: EntityType, id: str, component_list: List[Component]
):
self.entity_type = entity_type
self.id = id
self.components = {}
for component in component_list:
self.add_component(component)
def get_component(self, component_type: ComponentType) -> Optional[Component]:
return self.components[component_type]
def add_component(self, component: Component) -> None:
self.components[component.component_type] = component
class EntityManager:
def __init__(self):
self.entities = {}
self.component_entities = {}
def update(self) -> None:
self.component_entities = {}
for entity in self.entities.values():
for component in entity.components.values():
if component.component_type not in self.component_entities:
self.component_entities[component.component_type] = set()
self.component_entities[component.component_type].add(entity)
def get_entities_with_component(
self, component_type: ComponentType
) -> List[Entity]:
return self.component_entities.get(component_type, [])
def add_entity(self, entity: Entity) -> None:
self.entities[entity.id] = entity
for component in entity.components.values():
if component.component_type not in self.component_entities:
self.component_entities[component.component_type] = set()
self.component_entities[component.component_type].add(entity)
def remove_entity(self, entity_id: str) -> None:
if entity_id in self.entities:
entity = self.entities[entity_id]
for component in entity.components.values():
if component.component_type in self.component_entities:
self.component_entities[component.component_type].remove(entity)
def get_entity(self, entity_id: str) -> Optional[Entity]:
return self.entities.get(entity_id)

View File

@ -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)

53
kennel/engine/game.py Normal file
View File

@ -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")

View File

View File

@ -0,0 +1,88 @@
from enum import Enum
from kennel.engine.entities.entity import EntityManager
from .system import System, SystemType
from abc import abstractmethod
import asyncio
class EventType(str, Enum):
ENTITY_BORN = "ENTITY_BORN"
ENTITY_POSITION_UPDATE = "ENTITY_POSITION_UPDATE"
ENTITY_DEATH = "ENTITY_DEATH"
class Event:
def __init__(self, event_type: EventType, data: dict):
self.event_type = event_type
self.data = data
def __str__(self):
return f"Event({self.event_type}, {self.data})"
def dict(self):
return {"event_type": self.event_type, "data": self.data}
@staticmethod
def from_dict(data: dict):
return Event(EventType(data["event_type"]), data["data"])
class Publishable:
@abstractmethod
async def publish(self, event: Event):
pass
class ClientEventTransformer:
@abstractmethod
def apply(self, event: Event) -> Event:
pass
class EventProcessor:
@abstractmethod
def accept(self, entity_manager: EntityManager, event: Event) -> None:
pass
class NetworkSystem(System):
def __init__(
self,
event_processor: EventProcessor,
client_event_transformer: ClientEventTransformer,
):
super().__init__(SystemType.NETWORK)
self.event_processor = event_processor
self.client_event_transformer = client_event_transformer
self.events = []
self.clients = {}
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
for event in self.events:
self.event_processor.accept(entity_manager, event)
client_events = [
self.client_event_transformer.apply(entity_manager, event)
for event in self.events
]
await asyncio.gather(
*[
client.publish(event)
for client in self.clients.values()
for event in client_events
]
)
self.events = []
def client_event(self, client_id: str, event: Event) -> None:
event.data["client_id"] = client_id
self.events.append(event)
def add_event(self, event: Event) -> None:
self.events.append(event)
def add_client(self, client_id: str, client: Publishable) -> None:
self.clients[client_id] = client
def remove_client(self, client_id: str) -> None:
del self.clients[client_id]

View File

@ -0,0 +1,31 @@
from enum import Enum
from kennel.engine.entities.entity import EntityManager
from abc import abstractmethod
class SystemType(str, Enum):
NETWORK = "NETWORK"
class System:
def __init__(self, system_type: SystemType):
self.system_type = system_type
@abstractmethod
async def update(self, entity_manager: EntityManager, delta_time: float):
pass
class SystemManager:
def __init__(self):
self.systems = {}
def add_system(self, system: System) -> None:
self.systems[system.system_type] = system
def get_system(self, system_type: SystemType) -> System:
return self.systems.get(system_type)
async def update(self, entity_manager: EntityManager, delta_time: float) -> None:
for system in self.systems.values():
await system.update(entity_manager, delta_time)

81
kennel/kennel.py Normal file
View File

@ -0,0 +1,81 @@
from kennel.engine.game import Game
from kennel.engine.components.component import ComponentType
from kennel.engine.entities.entity import EntityManager, Entity
from kennel.engine.entities.laser import Laser
from kennel.engine.systems.system import SystemManager
from kennel.engine.systems.network import (
NetworkSystem,
EventProcessor,
ClientEventTransformer,
Event,
EventType,
)
from kennel.config import config
from .app import logger
from typing import List
import uuid
entity_manager = EntityManager()
system_manager = SystemManager()
class KennelEventProcessor(EventProcessor):
def accept(self, entity_manager: EntityManager, event: Event) -> None:
if event.event_type == EventType.ENTITY_POSITION_UPDATE:
self._process_entity_position_update(entity_manager, event)
def _process_entity_position_update(
self, entity_manager: EntityManager, event: Event
) -> None:
entity = entity_manager.get_entity(event.data["entity_id"])
if entity is None:
logger.error(f"Entity {event.data['entity_id']} does not exist")
return
if event.data["client_id"] is None:
logger.error("Client ID is required for position updates")
return
controllable = entity.get_component(ComponentType.CONTROLLABLE)
if controllable is None:
logger.error(f"Entity {entity} is not controllable")
return
if controllable.by != event.data["client_id"]:
logger.error(
f"Entity {entity} is not controllable by client {event.data['client_id']}"
)
return
position = entity.get_component(ComponentType.POSITION)
if position is None:
logger.error(f"Entity {entity} has no position")
return
entity.add_component(position)
class KennelClientEventTransformer(ClientEventTransformer):
def apply(self, entity_manager: EntityManager, event: Event) -> Event:
if event.event_type == EventType.ENTITY_BORN:
id = event.data["id"]
entity = entity_manager.get_entity(id)
if entity is None:
logger.error(f"Entity {id} does not exist")
return event
for component_type in entity.components:
if component_type == ComponentType.CONTROLLABLE:
# Do not send controllable components to clients
continue
component = entity.get_component(component_type)
if component is not None:
event.data[component_type] = component.dict()
return event
system_manager.add_system(
NetworkSystem(EventProcessor(), KennelClientEventTransformer())
)
kennel = Game(entity_manager, system_manager, config.MIN_TIME_STEP)
def create_session_controllable_entities(session: str) -> List[Entity]:
laser = Laser(uuid.uuid4().hex, session)
return [laser]

45
kennel/kennelcats.py Normal file
View File

@ -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()]

View File

@ -1,57 +1,118 @@
from fastapi import (
FastAPI,
Request,
Response,
WebSocket,
WebSocketException,
status,
Cookie,
Depends,
)
from fastapi.staticfiles import StaticFiles
from kennel.engine.systems.system import SystemType
from kennel.engine.systems.network import Event, Publishable, EventType
from typing import Annotated, Optional
from .kennel import (
kennel,
system_manager,
entity_manager,
create_session_controllable_entities,
)
from .app import app, templates, logger
from .kennelcats import KennelCatService
from .middleware import logger_middleware
from .config import config
import asyncio
import uuid
import structlog
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app.mount("/static", StaticFiles(directory="static"), name="static")
app = FastAPI(
servers = [
{"url": "https://kennel.hatecomputers.club", "description": "prod"}
]
)
logger = structlog.get_logger()
loop = asyncio.get_event_loop()
@app.middleware("http")
async def logger_middleware(request: Request, call_next):
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
path=request.url.path,
method=request.method,
client_host=request.client.host,
request_id=str(uuid.uuid4()),
)
response = await call_next(request)
@app.on_event("startup")
async def startup_event():
logger.info("Starting Kennel...")
loop.create_task(kennel.run())
structlog.contextvars.bind_contextvars(
status_code=response.status_code,
)
# Exclude /healthcheck endpoint from producing logs
if request.url.path != "/healthcheck":
if 400 <= response.status_code < 500:
logger.warn("Client error")
elif response.status_code >= 500:
logger.error("Server error")
else:
logger.info("OK")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Stopping Kennel...")
kennel.stop()
loop.stop()
logger.info("Kennel stopped")
return response
templates = Jinja2Templates(directory="templates")
@app.get("/")
async def index(request: Request):
return templates.TemplateResponse(request=request, name="index.html")
@app.get("/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}")
session_entities = create_session_controllable_entities(session)
try:
network_system = system_manager.get_system(SystemType.NETWORK)
if network_system is None:
raise "Network system not found"
for entity in session_entities:
logger.info(f"Adding entity {entity.id} for session {session}")
entity_manager.add_entity(entity)
network_system.add_event(Event(EventType.ENTITY_BORN, {"id": entity.id}))
network_system.add_client(session, WebSocketClient(websocket))
while True:
message = await websocket.receive_json()
network_system.client_event(session, Event.from_dict(message))
except Exception as e:
logger.error(f"WebSocket exception {e}")
finally:
logger.info("Websocket connection closed")
for entity in session_entities:
logger.info(f"Removing entity {entity.id} for session {session}")
entity_manager.remove_entity(entity.id)
network_system.add_event(Event(EventType.ENTITY_DEATH, {"id": entity.id}))
network_system.remove_client(session)
await websocket.close()
@app.get("/healthcheck")
async def healthcheck():
return Response("hello")
@app.get("/")
async def read_main(request: Request):
return templates.TemplateResponse(
request=request, name="index.html"
)
app.mount("/static", StaticFiles(directory = "static"), name = "static")
return Response("healthy")

30
kennel/middleware.py Normal file
View File

@ -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

930
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,9 @@ fastapi = "^0.108.0"
structlog = "^23.2.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]
ruff = "^0.1.9"

View File

@ -1,5 +1,12 @@
window.onload = () => {
console.log('from js');
const kennelWindowEle = document.querySelector('#kennel-window');
kennelWindowEle.innerHTML = 'rendered from static/index.js';
}
const ws = new WebSocket("/ws");
ws.onopen = () => {
console.log("connected");
};
ws.onmessage = (e) => {
console.log(e);
};
ws.onclose = (e) => {
console.log("disconnected", e);
};
};