From 84cd75e3a34488ed77fbd6b89b9363a00d029e75 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 22:51:56 -0500 Subject: [PATCH] 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. --- content/mobs/goblin.toml | 6 ++ content/mobs/training_dummy.toml | 6 ++ src/mudlib/entity.py | 4 +- src/mudlib/mobs.py | 99 +++++++++++++++++++ tests/test_mobs.py | 161 +++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 content/mobs/goblin.toml create mode 100644 content/mobs/training_dummy.toml create mode 100644 src/mudlib/mobs.py create mode 100644 tests/test_mobs.py diff --git a/content/mobs/goblin.toml b/content/mobs/goblin.toml new file mode 100644 index 0000000..19b43fc --- /dev/null +++ b/content/mobs/goblin.toml @@ -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"] diff --git a/content/mobs/training_dummy.toml b/content/mobs/training_dummy.toml new file mode 100644 index 0000000..17c47e5 --- /dev/null +++ b/content/mobs/training_dummy.toml @@ -0,0 +1,6 @@ +name = "training dummy" +description = "a battered wooden training dummy" +pl = 200.0 +stamina = 100.0 +max_stamina = 100.0 +moves = [] diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index ae04ba1..cc94d97 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -1,6 +1,6 @@ """Base entity class for characters in the world.""" -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -28,3 +28,5 @@ class Mob(Entity): description: str = "" alive: bool = True + moves: list[str] = field(default_factory=list) + next_action_at: float = 0.0 diff --git a/src/mudlib/mobs.py b/src/mudlib/mobs.py new file mode 100644 index 0000000..7f135ea --- /dev/null +++ b/src/mudlib/mobs.py @@ -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 diff --git a/tests/test_mobs.py b/tests/test_mobs.py new file mode 100644 index 0000000..c5250af --- /dev/null +++ b/tests/test_mobs.py @@ -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