Add mob templates, registry, and spawn/despawn/query
Phase 1 of fightable mobs: MobTemplate dataclass loaded from TOML, global mobs list, spawn_mob/despawn_mob/get_nearby_mob with wrapping-aware distance. Mob entity gets moves and next_action_at fields.
This commit is contained in:
parent
ed6ffbdc5d
commit
2bab61ef8c
5 changed files with 275 additions and 1 deletions
6
content/mobs/goblin.toml
Normal file
6
content/mobs/goblin.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
name = "goblin"
|
||||||
|
description = "a snarling goblin with a crude club"
|
||||||
|
pl = 50.0
|
||||||
|
stamina = 40.0
|
||||||
|
max_stamina = 40.0
|
||||||
|
moves = ["punch left", "punch right", "sweep"]
|
||||||
6
content/mobs/training_dummy.toml
Normal file
6
content/mobs/training_dummy.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
name = "training dummy"
|
||||||
|
description = "a battered wooden training dummy"
|
||||||
|
pl = 200.0
|
||||||
|
stamina = 100.0
|
||||||
|
max_stamina = 100.0
|
||||||
|
moves = []
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Base entity class for characters in the world."""
|
"""Base entity class for characters in the world."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -28,3 +28,5 @@ class Mob(Entity):
|
||||||
|
|
||||||
description: str = ""
|
description: str = ""
|
||||||
alive: bool = True
|
alive: bool = True
|
||||||
|
moves: list[str] = field(default_factory=list)
|
||||||
|
next_action_at: float = 0.0
|
||||||
|
|
|
||||||
99
src/mudlib/mobs.py
Normal file
99
src/mudlib/mobs.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Mob template loading, global registry, and spawn/despawn/query."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MobTemplate:
|
||||||
|
"""Definition loaded from TOML — used to spawn Mob instances."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
pl: float
|
||||||
|
stamina: float
|
||||||
|
max_stamina: float
|
||||||
|
moves: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level registries
|
||||||
|
mob_templates: dict[str, MobTemplate] = {}
|
||||||
|
mobs: list[Mob] = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_mob_template(path: Path) -> MobTemplate:
|
||||||
|
"""Parse a mob TOML file into a MobTemplate."""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return MobTemplate(
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
pl=data["pl"],
|
||||||
|
stamina=data["stamina"],
|
||||||
|
max_stamina=data["max_stamina"],
|
||||||
|
moves=data.get("moves", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
||||||
|
"""Load all .toml files in a directory into a dict keyed by name."""
|
||||||
|
templates: dict[str, MobTemplate] = {}
|
||||||
|
for path in sorted(directory.glob("*.toml")):
|
||||||
|
template = load_mob_template(path)
|
||||||
|
templates[template.name] = template
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
|
||||||
|
"""Create a Mob instance from a template at the given position."""
|
||||||
|
mob = Mob(
|
||||||
|
name=template.name,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
pl=template.pl,
|
||||||
|
stamina=template.stamina,
|
||||||
|
max_stamina=template.max_stamina,
|
||||||
|
description=template.description,
|
||||||
|
moves=list(template.moves),
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
return mob
|
||||||
|
|
||||||
|
|
||||||
|
def despawn_mob(mob: Mob) -> None:
|
||||||
|
"""Remove a mob from the registry and mark it dead."""
|
||||||
|
mob.alive = False
|
||||||
|
if mob in mobs:
|
||||||
|
mobs.remove(mob)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nearby_mob(
|
||||||
|
name: str, x: int, y: int, world: Any, range_: int = 10
|
||||||
|
) -> Mob | None:
|
||||||
|
"""Find the closest alive mob matching name within range.
|
||||||
|
|
||||||
|
Uses wrapping-aware distance (same pattern as send_nearby_message).
|
||||||
|
"""
|
||||||
|
best: Mob | None = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
|
||||||
|
for mob in mobs:
|
||||||
|
if not mob.alive or mob.name != name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dx = abs(mob.x - x)
|
||||||
|
dy = abs(mob.y - y)
|
||||||
|
dx = min(dx, world.width - dx)
|
||||||
|
dy = min(dy, world.height - dy)
|
||||||
|
|
||||||
|
if dx <= range_ and dy <= range_:
|
||||||
|
dist = dx + dy
|
||||||
|
if dist < best_dist:
|
||||||
|
best = mob
|
||||||
|
best_dist = dist
|
||||||
|
|
||||||
|
return best
|
||||||
161
tests/test_mobs.py
Normal file
161
tests/test_mobs.py
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
"""Tests for mob templates, registry, and spawn/despawn."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.mobs import (
|
||||||
|
MobTemplate,
|
||||||
|
despawn_mob,
|
||||||
|
get_nearby_mob,
|
||||||
|
load_mob_template,
|
||||||
|
load_mob_templates,
|
||||||
|
mobs,
|
||||||
|
spawn_mob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_mobs():
|
||||||
|
"""Clear mobs list before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def goblin_toml(tmp_path):
|
||||||
|
"""Create a goblin TOML file."""
|
||||||
|
path = tmp_path / "goblin.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "goblin"\n'
|
||||||
|
'description = "a snarling goblin with a crude club"\n'
|
||||||
|
"pl = 50.0\n"
|
||||||
|
"stamina = 40.0\n"
|
||||||
|
"max_stamina = 40.0\n"
|
||||||
|
'moves = ["punch left", "punch right", "sweep"]\n'
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dummy_toml(tmp_path):
|
||||||
|
"""Create a training dummy TOML file."""
|
||||||
|
path = tmp_path / "training_dummy.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "training dummy"\n'
|
||||||
|
'description = "a battered wooden training dummy"\n'
|
||||||
|
"pl = 200.0\n"
|
||||||
|
"stamina = 100.0\n"
|
||||||
|
"max_stamina = 100.0\n"
|
||||||
|
"moves = []\n"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadTemplate:
|
||||||
|
def test_load_single_template(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
assert template.name == "goblin"
|
||||||
|
assert template.description == "a snarling goblin with a crude club"
|
||||||
|
assert template.pl == 50.0
|
||||||
|
assert template.stamina == 40.0
|
||||||
|
assert template.max_stamina == 40.0
|
||||||
|
assert template.moves == ["punch left", "punch right", "sweep"]
|
||||||
|
|
||||||
|
def test_load_template_no_moves(self, dummy_toml):
|
||||||
|
template = load_mob_template(dummy_toml)
|
||||||
|
assert template.name == "training dummy"
|
||||||
|
assert template.moves == []
|
||||||
|
|
||||||
|
def test_load_all_templates(self, goblin_toml, dummy_toml):
|
||||||
|
templates = load_mob_templates(goblin_toml.parent)
|
||||||
|
assert "goblin" in templates
|
||||||
|
assert "training dummy" in templates
|
||||||
|
assert len(templates) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpawnDespawn:
|
||||||
|
def test_spawn_creates_mob(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 10, 20)
|
||||||
|
assert isinstance(mob, Mob)
|
||||||
|
assert mob.name == "goblin"
|
||||||
|
assert mob.x == 10
|
||||||
|
assert mob.y == 20
|
||||||
|
assert mob.pl == 50.0
|
||||||
|
assert mob.stamina == 40.0
|
||||||
|
assert mob.max_stamina == 40.0
|
||||||
|
assert mob.moves == ["punch left", "punch right", "sweep"]
|
||||||
|
assert mob.alive is True
|
||||||
|
assert mob in mobs
|
||||||
|
|
||||||
|
def test_spawn_adds_to_registry(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 0, 0)
|
||||||
|
spawn_mob(template, 5, 5)
|
||||||
|
assert len(mobs) == 2
|
||||||
|
|
||||||
|
def test_despawn_removes_from_list(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
despawn_mob(mob)
|
||||||
|
assert mob not in mobs
|
||||||
|
assert mob.alive is False
|
||||||
|
|
||||||
|
def test_despawn_sets_alive_false(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
despawn_mob(mob)
|
||||||
|
assert mob.alive is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNearbyMob:
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_world(self):
|
||||||
|
w = MagicMock()
|
||||||
|
w.width = 256
|
||||||
|
w.height = 256
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 5, 5)
|
||||||
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
|
assert found is mob
|
||||||
|
|
||||||
|
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 100, 100)
|
||||||
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 5, 5)
|
||||||
|
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
far_mob = spawn_mob(template, 8, 8)
|
||||||
|
close_mob = spawn_mob(template, 1, 1)
|
||||||
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
|
assert found is close_mob
|
||||||
|
|
||||||
|
def test_skips_dead_mobs(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 5, 5)
|
||||||
|
mob.alive = False
|
||||||
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_wrapping_distance(self, goblin_toml, mock_world):
|
||||||
|
"""Mob near world edge is close to player at opposite edge."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 254, 254)
|
||||||
|
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
|
||||||
|
assert found is mob
|
||||||
Loading…
Reference in a new issue