Add world cache to speedup startup
This commit is contained in:
parent
a10f3d4e70
commit
9948a36f5f
3 changed files with 93 additions and 5 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
import time
|
import time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
|
@ -136,12 +137,16 @@ async def run_server() -> None:
|
||||||
"""Start the MUD telnet server."""
|
"""Start the MUD telnet server."""
|
||||||
global _world
|
global _world
|
||||||
|
|
||||||
# Generate world once at startup
|
# Generate world once at startup (cached to build/ after first run)
|
||||||
log.info("generating world (seed=42, 1000x1000)...")
|
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
||||||
|
log.info("loading world (seed=42, 1000x1000)...")
|
||||||
t0 = time.monotonic()
|
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
|
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
|
# Inject world into command modules
|
||||||
mudlib.commands.fly.world = _world
|
mudlib.commands.fly.world = _world
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Terrain generation for MUD worlds using deterministic noise."""
|
"""Terrain generation for MUD worlds using deterministic noise."""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import pathlib
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,14 +71,33 @@ class World:
|
||||||
"water": "~",
|
"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.seed = seed
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
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
|
# generate terrain grid
|
||||||
self.terrain = self._generate_terrain()
|
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]]:
|
def _generate_elevation(self) -> list[list[float]]:
|
||||||
"""Generate tileable elevation map using fractal noise.
|
"""Generate tileable elevation map using fractal noise.
|
||||||
|
|
||||||
|
|
@ -249,6 +269,32 @@ class World:
|
||||||
self._generate_rivers(terrain, elevation)
|
self._generate_rivers(terrain, elevation)
|
||||||
return terrain
|
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]:
|
def wrap(self, x: int, y: int) -> tuple[int, int]:
|
||||||
"""Wrap coordinates to stay within world bounds."""
|
"""Wrap coordinates to stay within world bounds."""
|
||||||
return x % self.width, y % self.height
|
return x % self.width, y % self.height
|
||||||
|
|
|
||||||
|
|
@ -204,3 +204,40 @@ def test_large_world_generation():
|
||||||
assert world.get_tile(0, 0) in {".", "^", "~", "T", ":"}
|
assert world.get_tile(0, 0) in {".", "^", "~", "T", ":"}
|
||||||
assert world.get_tile(500, 500) in {".", "^", "~", "T", ":"}
|
assert world.get_tile(500, 500) in {".", "^", "~", "T", ":"}
|
||||||
assert world.get_tile(999, 999) 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue