diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index d2ecd56..777bb49 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -34,9 +34,19 @@ async def cmd_look(player: Player, args: str) -> None: if other.name == player.name: continue - # Calculate relative position - rel_x = other.x - player.x + center_x - rel_y = other.y - player.y + center_y + # Calculate relative position (shortest path wrapping) + dx = other.x - player.x + dy = other.y - player.y + if dx > world.width // 2: + dx -= world.width + elif dx < -(world.width // 2): + dx += world.width + if dy > world.height // 2: + dy -= world.height + elif dy < -(world.height // 2): + dy += world.height + rel_x = dx + center_x + rel_y = dy + center_y # Check if within viewport bounds if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: diff --git a/src/mudlib/commands/movement.py b/src/mudlib/commands/movement.py index a1bac65..e3dc767 100644 --- a/src/mudlib/commands/movement.py +++ b/src/mudlib/commands/movement.py @@ -65,8 +65,7 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) -> dy: Y delta direction_name: Full name of the direction for messages """ - target_x = player.x + dx - target_y = player.y + dy + target_x, target_y = world.wrap(player.x + dx, player.y + dy) # Check if the target is passable if not world.is_passable(target_x, target_y): @@ -111,8 +110,12 @@ async def send_nearby_message(player: Player, x: int, y: int, message: str) -> N if other.name == player.name: continue - # Check if other player is within viewport range - if abs(other.x - x) <= viewport_range and abs(other.y - y) <= viewport_range: + # Check if other player is within viewport range (wrapping) + dx_dist = abs(other.x - x) + dy_dist = abs(other.y - y) + dx_dist = min(dx_dist, world.width - dx_dist) + dy_dist = min(dy_dist, world.height - dy_dist) + if dx_dist <= viewport_range and dy_dist <= viewport_range: other.writer.write(message) await other.writer.drain() diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 76c454b..7bc6e28 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -38,7 +38,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, if world.is_passable(start_x, start_y): return start_x, start_y - # Spiral outward from the starting position + # Spiral outward from the starting position (wrapping) for radius in range(1, 100): for dx in range(-radius, radius + 1): for dy in range(-radius, radius + 1): @@ -46,12 +46,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, if abs(dx) != radius and abs(dy) != radius: continue - x = start_x + dx - y = start_y + dy - - # Check bounds - if not (0 <= x < world.width and 0 <= y < world.height): - continue + x, y = world.wrap(start_x + dx, start_y + dy) if world.is_passable(x, y): return x, y diff --git a/src/mudlib/world/terrain.py b/src/mudlib/world/terrain.py index 2781636..58e4af7 100644 --- a/src/mudlib/world/terrain.py +++ b/src/mudlib/world/terrain.py @@ -12,13 +12,18 @@ def _make_perm(seed: int) -> list[int]: return p + p -def _noise(perm: list[int], x: float, y: float) -> float: - """2D Perlin noise, fully inlined for speed.""" +def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float: + """2D tileable Perlin noise. + + Wraps seamlessly with period px along x and py along y. + """ # unit grid cell fx = math.floor(x) fy = math.floor(y) - xi = int(fx) & 255 - yi = int(fy) & 255 + xi = int(fx) % px + yi = int(fy) % py + xi1 = (xi + 1) % px + yi1 = (yi + 1) % py # relative coordinates within cell xf = x - fx @@ -28,11 +33,11 @@ def _noise(perm: list[int], x: float, y: float) -> float: u = xf * xf * xf * (xf * (xf * 6 - 15) + 10) v = yf * yf * yf * (yf * (yf * 6 - 15) + 10) - # hash coordinates of 4 corners + # hash coordinates of 4 corners (wrapping) aa = perm[perm[xi] + yi] - ab = perm[perm[xi] + yi + 1] - ba = perm[perm[xi + 1] + yi] - bb = perm[perm[xi + 1] + yi + 1] + ab = perm[perm[xi] + yi1] + ba = perm[perm[xi1] + yi] + bb = perm[perm[xi1] + yi1] # grad + lerp inlined for all 4 corners xf1 = xf - 1 @@ -74,19 +79,20 @@ class World: self.terrain = self._generate_terrain() def _generate_elevation(self) -> list[list[float]]: - """Generate elevation map using fractal noise. + """Generate tileable elevation map using fractal noise. Computes at 1/4 resolution and interpolates up for speed. + Tiles seamlessly in both axes so the world can wrap. """ w = self.width h = self.height perm = _make_perm(self.seed) noise = _noise - # generate at 1/4 resolution + # generate at 1/4 resolution (no boundary padding, we wrap instead) step = 4 - cw = w // step + 2 # +2 for interpolation boundary - ch = h // step + 2 + cw = w // step + ch = h // step coarse = [[0.0] * cw for _ in range(ch)] min_val = float("inf") @@ -95,14 +101,15 @@ class World: for cy in range(ch): row = coarse[cy] for cx in range(cw): - # map coarse coords back to world space + # map coarse coords back to world space [0, 1) wx = cx * step / w wy = cy * step / h + # each octave tiles at its own frequency value = ( - noise(perm, wx * 4, wy * 4) - + noise(perm, wx * 8, wy * 8) * 0.5 - + noise(perm, wx * 16, wy * 16) * 0.25 + noise(perm, wx * 4, wy * 4, 4, 4) + + noise(perm, wx * 8, wy * 8, 8, 8) * 0.5 + + noise(perm, wx * 16, wy * 16, 16, 16) * 0.25 ) row[cx] = value if value < min_val: @@ -119,15 +126,16 @@ class World: for cx in range(cw): row[cx] = (row[cx] - min_val) * inv_range - # bilinear interpolation to full resolution + # bilinear interpolation to full resolution (wrapping at edges) inv_step = 1.0 / step elevation = [[0.0] * w for _ in range(h)] for y in range(h): cy_f = y * inv_step - cy0 = int(cy_f) - cy1 = min(cy0 + 1, ch - 1) - fy = cy_f - cy0 + cy_floor = int(cy_f) + cy0 = cy_floor % ch + cy1 = (cy0 + 1) % ch + fy = cy_f - cy_floor fy_inv = 1.0 - fy row_0 = coarse[cy0] row_1 = coarse[cy1] @@ -135,9 +143,10 @@ class World: for x in range(w): cx_f = x * inv_step - cx0 = int(cx_f) - cx1 = min(cx0 + 1, cw - 1) - fx = cx_f - cx0 + cx_floor = int(cx_f) + cx0 = cx_floor % cw + cx1 = (cx0 + 1) % cw + fx = cx_f - cx_floor # bilinear interpolation out_row[x] = ( @@ -187,47 +196,44 @@ class World: ) -> None: """Generate rivers from mountains to water bodies.""" rng = random.Random(self.seed) + w = self.width + h = self.height # find some high-elevation starting points - num_rivers = max(5, (self.width * self.height) // 50000) + num_rivers = max(5, (w * h) // 50000) for _ in range(num_rivers): # pick random high elevation point - x = rng.randint(0, self.width - 1) - y = rng.randint(0, self.height - 1) + x = rng.randint(0, w - 1) + y = rng.randint(0, h - 1) if elevation[y][x] < 0.6: continue - # trace downhill + # trace downhill (wrapping at edges) visited = set() while True: if (x, y) in visited or len(visited) > 200: break visited.add((x, y)) - # if we hit water or edge, stop + # if we hit water, stop if terrain[y][x] == self.TERRAIN_CHARS["water"]: break - if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1: - break # carve river if elevation[y][x] < 0.75: # don't carve through mountains terrain[y][x] = self.TERRAIN_CHARS["water"] - # find steepest descent to neighbor + # find steepest descent to neighbor (wrapping) current_elevation = elevation[y][x] best_x, best_y = x, y best_elevation = current_elevation for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - nx, ny = x + dx, y + dy - if ( - 0 <= nx < self.width - and 0 <= ny < self.height - and elevation[ny][nx] < best_elevation - ): + nx = (x + dx) % w + ny = (y + dy) % h + if elevation[ny][nx] < best_elevation: best_elevation = elevation[ny][nx] best_x, best_y = nx, ny @@ -243,39 +249,36 @@ class World: self._generate_rivers(terrain, elevation) return terrain + def wrap(self, x: int, y: int) -> tuple[int, int]: + """Wrap coordinates to stay within world bounds.""" + return x % self.width, y % self.height + def get_tile(self, x: int, y: int) -> str: - """Return terrain character at position.""" - if not (0 <= x < self.width and 0 <= y < self.height): - raise ValueError(f"Coordinates ({x}, {y}) out of bounds") - return self.terrain[y][x] + """Return terrain character at position (wrapping).""" + wx, wy = self.wrap(x, y) + return self.terrain[wy][wx] def is_passable(self, x: int, y: int) -> bool: - """Check if position is passable.""" + """Check if position is passable (wrapping).""" tile = self.get_tile(x, y) return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]} def get_viewport( self, cx: int, cy: int, width: int, height: int ) -> list[list[str]]: - """Return 2D slice of terrain centered on (cx, cy).""" + """Return 2D slice of terrain centered on (cx, cy), wrapping at edges.""" viewport = [] - # calculate bounds half_width = width // 2 half_height = height // 2 start_y = cy - half_height - end_y = start_y + height - start_x = cx - half_width - end_x = start_x + width - for y in range(start_y, end_y): + for dy in range(height): row = [] - for x in range(start_x, end_x): - # clamp to world bounds - wx = max(0, min(x, self.width - 1)) - wy = max(0, min(y, self.height - 1)) + for dx in range(width): + wx, wy = self.wrap(start_x + dx, start_y + dy) row.append(self.terrain[wy][wx]) viewport.append(row) diff --git a/tests/test_commands.py b/tests/test_commands.py index e1b4fe0..04e2687 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -25,7 +25,10 @@ def mock_reader(): @pytest.fixture def mock_world(): world = MagicMock() + world.width = 100 + world.height = 100 world.is_passable = MagicMock(return_value=True) + world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100)) # Create a 21x11 viewport filled with "." viewport = [["." for _ in range(21)] for _ in range(11)] world.get_viewport = MagicMock(return_value=viewport) diff --git a/tests/test_terrain.py b/tests/test_terrain.py index bab0a3e..07f113d 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -1,5 +1,3 @@ -import pytest - from mudlib.world.terrain import World @@ -36,12 +34,11 @@ def test_world_dimensions(): world.get_tile(0, 0) world.get_tile(99, 49) - # should raise for out of bounds - with pytest.raises((IndexError, ValueError)): - world.get_tile(100, 0) - - with pytest.raises((IndexError, ValueError)): - world.get_tile(0, 50) + # coordinates wrap instead of raising + assert world.get_tile(100, 0) == world.get_tile(0, 0) + assert world.get_tile(0, 50) == world.get_tile(0, 0) + assert world.get_tile(-1, 0) == world.get_tile(99, 0) + assert world.get_tile(0, -1) == world.get_tile(0, 49) def test_get_tile_returns_valid_terrain(): @@ -129,19 +126,23 @@ def test_get_viewport_centers_correctly(): assert center_tile == world.get_tile(50, 50) -def test_get_viewport_handles_edges(): - """Viewport handles world boundaries correctly.""" +def test_get_viewport_wraps_at_edges(): + """Viewport wraps around world boundaries.""" world = World(seed=42, width=100, height=100) - # near top-left corner - viewport = world.get_viewport(2, 2, width=5, height=5) + # near top-left corner - viewport should wrap to bottom/right + viewport = world.get_viewport(0, 0, width=5, height=5) assert len(viewport) == 5 assert len(viewport[0]) == 5 + # top-left of viewport wraps to bottom-right of world + assert viewport[0][0] == world.get_tile(98, 98) - # near bottom-right corner - viewport = world.get_viewport(97, 97, width=5, height=5) + # near bottom-right corner - viewport should wrap to top/left + viewport = world.get_viewport(99, 99, width=5, height=5) assert len(viewport) == 5 assert len(viewport[0]) == 5 + # bottom-right of viewport wraps to top-left of world + assert viewport[4][4] == world.get_tile(1, 1) def test_terrain_distribution_reasonable(): @@ -178,6 +179,23 @@ def test_rivers_exist(): assert water_count > 0 +def test_terrain_tiles_seamlessly(): + """Terrain at opposite edges matches for seamless wrapping.""" + world = World(seed=42, width=100, height=100) + + # check that a strip along the right edge is continuous with the left edge + # by verifying the elevation-derived terrain doesn't have a hard seam. + # the noise is tileable, so adjacent tiles across the boundary should be + # from the same smooth noise field (not identical, but not a cliff). + # we verify the wrap helper itself works correctly: + for y in range(100): + assert world.get_tile(-1, y) == world.get_tile(99, y) + assert world.get_tile(100, y) == world.get_tile(0, y) + for x in range(100): + assert world.get_tile(x, -1) == world.get_tile(x, 99) + assert world.get_tile(x, 100) == world.get_tile(x, 0) + + def test_large_world_generation(): """Can generate a 1000x1000 world without errors.""" world = World(seed=42, width=1000, height=1000)