diff --git a/src/mudlib/server.py b/src/mudlib/server.py index a31caeb..8b0581f 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -2,6 +2,7 @@ import asyncio import logging +import pathlib import time from typing import cast @@ -136,12 +137,16 @@ async def run_server() -> None: """Start the MUD telnet server.""" global _world - # Generate world once at startup - log.info("generating world (seed=42, 1000x1000)...") + # Generate world once at startup (cached to build/ after first run) + cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build" + log.info("loading world (seed=42, 1000x1000)...") t0 = time.monotonic() - _world = World(seed=42, width=1000, height=1000) + _world = World(seed=42, width=1000, height=1000, cache_dir=cache_dir) elapsed = time.monotonic() - t0 - log.info("world generated in %.2fs", elapsed) + if _world.cached: + log.info("world loaded from cache in %.2fs", elapsed) + else: + log.info("world generated in %.2fs (cached for next run)", elapsed) # Inject world into command modules mudlib.commands.fly.world = _world diff --git a/src/mudlib/world/terrain.py b/src/mudlib/world/terrain.py index 58e4af7..47cee1c 100644 --- a/src/mudlib/world/terrain.py +++ b/src/mudlib/world/terrain.py @@ -1,6 +1,7 @@ """Terrain generation for MUD worlds using deterministic noise.""" import math +import pathlib import random @@ -70,14 +71,33 @@ class World: "water": "~", } - def __init__(self, seed: int = 42, width: int = 1000, height: int = 1000): + def __init__( + self, + seed: int = 42, + width: int = 1000, + height: int = 1000, + cache_dir: pathlib.Path | None = None, + ): self.seed = seed self.width = width self.height = height + self.cached = False + + # try loading from cache + if cache_dir is not None: + terrain = self._load_cache(cache_dir) + if terrain is not None: + self.terrain = terrain + self.cached = True + return # generate terrain grid self.terrain = self._generate_terrain() + # save to cache for next time + if cache_dir is not None: + self._save_cache(cache_dir) + def _generate_elevation(self) -> list[list[float]]: """Generate tileable elevation map using fractal noise. @@ -249,6 +269,32 @@ class World: self._generate_rivers(terrain, elevation) return terrain + def _cache_path(self, cache_dir: pathlib.Path) -> pathlib.Path: + return cache_dir / f"terrain_{self.seed}_{self.width}x{self.height}.bin" + + def _load_cache(self, cache_dir: pathlib.Path) -> list[list[str]] | None: + """Load terrain from a cached binary file. Returns None on miss.""" + path = self._cache_path(cache_dir) + if not path.exists(): + return None + data = path.read_bytes() + if len(data) != self.width * self.height: + return None + terrain = [] + for y in range(self.height): + offset = y * self.width + row = [chr(b) for b in data[offset : offset + self.width]] + terrain.append(row) + return terrain + + def _save_cache(self, cache_dir: pathlib.Path) -> None: + """Save terrain to a binary file (1 byte per tile).""" + cache_dir.mkdir(parents=True, exist_ok=True) + data = bytearray() + for row in self.terrain: + data.extend(ord(c) for c in row) + self._cache_path(cache_dir).write_bytes(bytes(data)) + def wrap(self, x: int, y: int) -> tuple[int, int]: """Wrap coordinates to stay within world bounds.""" return x % self.width, y % self.height diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 07f113d..9ba2964 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -204,3 +204,40 @@ def test_large_world_generation(): assert world.get_tile(0, 0) in {".", "^", "~", "T", ":"} assert world.get_tile(500, 500) in {".", "^", "~", "T", ":"} assert world.get_tile(999, 999) in {".", "^", "~", "T", ":"} + + +# --- terrain caching --- + + +def test_world_saves_cache(tmp_path): + """World saves terrain cache when cache_dir is provided.""" + world = World(seed=42, width=100, height=100, cache_dir=tmp_path) + cache_file = tmp_path / "terrain_42_100x100.bin" + assert cache_file.exists() + assert cache_file.stat().st_size == 100 * 100 # 1 byte per tile + assert not world.cached # first run generates, doesn't load from cache + + +def test_world_loads_from_cache(tmp_path): + """World loads terrain from cache on second creation.""" + world1 = World(seed=42, width=100, height=100, cache_dir=tmp_path) + world2 = World(seed=42, width=100, height=100, cache_dir=tmp_path) + + for y in range(100): + for x in range(100): + assert world1.get_tile(x, y) == world2.get_tile(x, y) + + assert world2.cached + + +def test_world_cache_different_params(tmp_path): + """Different seed/size doesn't use wrong cache.""" + World(seed=42, width=100, height=100, cache_dir=tmp_path) + world2 = World(seed=99, width=100, height=100, cache_dir=tmp_path) + assert not world2.cached # different seed, must generate + + +def test_world_no_cache_by_default(): + """World doesn't cache when no cache_dir provided.""" + world = World(seed=42, width=50, height=50) + assert not world.cached