Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ Execute one simulation step:
2. Route queued commands to registered handlers
3. Increment step counter

#### `drain() -> int`

Route pending commands present at drain start toward registered handlers without running systems.

- Only commands queued at the start of the call are processed (snapshot semantics).
- Commands emitted by handlers during drain remain pending for subsequent processing.
- Exceptions in individual handlers are logged and do not stop other handlers.
- Returns the number of commands routed to handlers.

---

## World
Expand Down
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ The **Runtime** orchestrates the simulation loop. It is the main entry point for
- Execute simulation steps in correct order
- Provide convenient access to subsystems

**Runtime notes:**
- `drain()` processes a snapshot of pending commands (commands queued at call start); commands emitted by handlers remain pending.
- Command handler exceptions are logged and isolated — a failing handler does not stop other commands.

**Key Insight:** Runtime separates the "how" (orchestration) from the "what" (simulation logic). The World contains the simulation; Runtime runs it.

### 2. World
Expand Down
73 changes: 51 additions & 22 deletions hive/runtime.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,91 @@
"""Runtime: simple orchestration for World, Dispatcher and Command Routing."""

from typing import TypeVar
import logging

from .core import World
from .command.dispatcher import CommandDispatcher
from .command.router import CommandRouter

T = TypeVar("T")
logger = logging.getLogger(__name__)


class Runtime:
"""Main runtime that orchestrates the ECS simulation.

Runtime runs the simulation.
World is the simulation.
Store owns the simulation data (entities, components).
Resources hold static data (config, assets).
"""
"""Orchestrate World, Dispatcher and Router."""

def __init__(self):
self._world = World()
self._dispatcher = CommandDispatcher()
self._router = CommandRouter()
self.steps = 0

self.event_bus = self._world.event_bus
self.steps = 0

@property
def world(self) -> World:
"""Access the simulation world."""
return self._world

@property
def resources(self):
"""Access resources (static data like config, assets)."""
return self._world.resources

@property
def router(self) -> CommandRouter:
"""Access the command router for registering command handlers."""
return self._router

@property
def dispatcher(self) -> CommandDispatcher:
"""Access the command dispatcher for dispatching commands."""
return self._dispatcher

@property
def has_pending(self) -> bool:
return bool(self._dispatcher.queue)

def _handle_commands(self, commands) -> int:
"""Process commands with per-command exception handling.

Returns number of commands routed to a handler.
"""
processed = 0
for cmd in commands:
try:
routed = self._router.route(cmd, self._world)
except Exception:
logger.exception(f"Command handler failed for {type(cmd).__name__}")
else:
if routed:
processed += 1
return processed

def drain(self) -> int:
"""Route pending commands present at drain start toward handlers.

Only commands that were in the dispatcher when drain() was called are
processed. Commands emitted by handlers during this call remain in the
dispatcher's queue and will be processed later.
"""
commands = self._dispatcher.pop_all()
if not commands:
return 0
return self._handle_commands(commands)

def step(self) -> None:
"""Execute one simulation step.

Entity cleanup (e.g., Destroyed component) should be handled
by user-registered systems.
Runs systems, routes commands produced during the step, and increments
the global `steps` counter.
"""
# Phase 1: Systems run and emit commands
self._world.step(self._dispatcher)

# Phase 2: Route commands to handlers
commands = self._dispatcher.pop_all()
if commands:
self._router.handle_all(commands, self._world)

self._handle_commands(commands)
self.steps += 1

def step_until_idle(self) -> int:
"""Run steps until there are no pending commands.

Returns the number of steps executed during this call.
"""
steps = 0
while self._dispatcher.queue:
self.step()
steps += 1
return steps
76 changes: 76 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import logging

from hive import Runtime


class CmdA:
pass


class CmdB:
pass


class BadCmd:
pass


def test_drain_processes_initial_queue():
rt = Runtime()
called = []

def handle_a(cmd, world):
called.append(("A", cmd))

rt.router.register(CmdA, handle_a)

rt.dispatcher.dispatch(CmdA())
rt.dispatcher.dispatch(CmdA())

n = rt.drain()
assert n == 2
assert len(called) == 2
assert not rt.dispatcher.queue


def test_drain_does_not_process_commands_added_by_handlers():
rt = Runtime()
called = []

def handle_a(cmd, world):
rt.dispatcher.dispatch(CmdB())
called.append("A")

def handle_b(cmd, world):
called.append("B")

rt.router.register(CmdA, handle_a)
rt.router.register(CmdB, handle_b)

rt.dispatcher.dispatch(CmdA())
n = rt.drain()
assert n == 1
assert called == ["A"]
assert len(rt.dispatcher.queue) == 1


def test_handler_exception_is_caught_and_logged(caplog):
rt = Runtime()

def bad_handler(cmd, world):
raise RuntimeError("handler failed")

def good_handler(cmd, world):
pass

rt.router.register(BadCmd, bad_handler)
rt.router.register(CmdA, good_handler)

rt.dispatcher.dispatch(BadCmd())
rt.dispatcher.dispatch(CmdA())

caplog.set_level(logging.ERROR)
n = rt.drain()
# only CmdA routed successfully (BadCmd raised)
assert n == 1
assert any("Command handler failed" in rec.getMessage() for rec in caplog.records)
Loading