BSP: add room adjacency graph for corridor generation (closes #210)
New features: - bsp.adjacency[i] returns tuple of neighbor leaf indices - bsp.get_leaf(index) returns BSPNode by leaf index (O(1) lookup) - node.leaf_index returns this leaf's index (0..n-1) or None - node.adjacent_tiles[j] returns tuple of Vector wall tiles bordering neighbor j Implementation details: - Lazy-computed adjacency cache with generation-based invalidation - O(n²) pairwise adjacency check on first access - Wall tiles computed per-direction (not symmetric) for correct perspective - Supports 'in' operator: `5 in leaf.adjacent_tiles` Code review fixes applied: - split_once now increments generation to invalidate cache - Wall tile cache uses (self, neighbor) key, not symmetric - Added sq_contains for 'in' operator support - Documented wall tile semantics (tiles on THIS leaf's boundary) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a86602789
commit
8628ac164b
4 changed files with 1058 additions and 2 deletions
323
tests/unit/bsp_adjacency_test.py
Normal file
323
tests/unit/bsp_adjacency_test.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for BSP adjacency graph feature (#210)"""
|
||||
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def test_adjacency_basic():
|
||||
"""Test basic adjacency on a simple 2-leaf BSP"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
# Split once - creates 2 leaves
|
||||
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) == 2, f"Expected 2 leaves, got {len(leaves)}"
|
||||
|
||||
# Access adjacency
|
||||
adj = bsp.adjacency
|
||||
assert len(adj) == 2, f"Expected adjacency len 2, got {len(adj)}"
|
||||
|
||||
# Each leaf should be adjacent to the other
|
||||
neighbors_0 = adj[0]
|
||||
neighbors_1 = adj[1]
|
||||
|
||||
assert 1 in neighbors_0, f"Leaf 0 should be adjacent to leaf 1, got {neighbors_0}"
|
||||
assert 0 in neighbors_1, f"Leaf 1 should be adjacent to leaf 0, got {neighbors_1}"
|
||||
|
||||
print(" test_adjacency_basic: PASS")
|
||||
|
||||
def test_leaf_indexing():
|
||||
"""Test that leaf_index property works correctly"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
|
||||
# Each leaf should have a valid index
|
||||
for i, leaf in enumerate(leaves):
|
||||
assert leaf.leaf_index == i, f"Leaf {i} has index {leaf.leaf_index}"
|
||||
|
||||
# Non-leaves should return None
|
||||
root = bsp.root
|
||||
if not root.is_leaf:
|
||||
assert root.leaf_index is None, "Non-leaf should have leaf_index=None"
|
||||
|
||||
print(" test_leaf_indexing: PASS")
|
||||
|
||||
def test_adjacency_symmetry():
|
||||
"""Test that adjacency is symmetric: if A adjacent to B, then B adjacent to A"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=3, min_size=(10, 10), seed=42)
|
||||
|
||||
adj = bsp.adjacency
|
||||
n = len(adj)
|
||||
|
||||
for i in range(n):
|
||||
for j in adj[i]:
|
||||
assert i in adj[j], f"Adjacency not symmetric: {i} -> {j} but not {j} -> {i}"
|
||||
|
||||
print(" test_adjacency_symmetry: PASS")
|
||||
|
||||
def test_adjacent_tiles_basic():
|
||||
"""Test that adjacent_tiles returns Vector tuples"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) == 2
|
||||
|
||||
leaf0 = leaves[0]
|
||||
neighbors = bsp.adjacency[0]
|
||||
assert len(neighbors) > 0, "Leaf 0 should have neighbors"
|
||||
|
||||
neighbor_idx = neighbors[0]
|
||||
wall_tiles = leaf0.adjacent_tiles[neighbor_idx]
|
||||
|
||||
assert len(wall_tiles) > 0, "Should have wall tiles"
|
||||
|
||||
# Check that wall tiles are Vector objects
|
||||
first_tile = wall_tiles[0]
|
||||
assert hasattr(first_tile, 'x') and hasattr(first_tile, 'y'), \
|
||||
f"Wall tile should be a Vector, got {type(first_tile)}"
|
||||
|
||||
print(" test_adjacent_tiles_basic: PASS")
|
||||
|
||||
def test_adjacent_tiles_keyerror():
|
||||
"""Test that non-adjacent lookups raise KeyError"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=3, min_size=(10, 10), seed=42)
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
|
||||
# Find a non-adjacent pair
|
||||
adj = bsp.adjacency
|
||||
for i in range(len(leaves)):
|
||||
for j in range(len(leaves)):
|
||||
if i != j and j not in adj[i]:
|
||||
# i and j are not adjacent
|
||||
try:
|
||||
_ = leaves[i].adjacent_tiles[j]
|
||||
assert False, f"Expected KeyError for non-adjacent pair {i}, {j}"
|
||||
except KeyError:
|
||||
pass # Expected
|
||||
print(" test_adjacent_tiles_keyerror: PASS")
|
||||
return
|
||||
|
||||
# If we get here, all pairs are adjacent (unlikely with depth 3)
|
||||
print(" test_adjacent_tiles_keyerror: SKIP (all pairs adjacent)")
|
||||
|
||||
def test_cache_invalidation():
|
||||
"""Test that cache is invalidated on clear() and split_recursive()"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
|
||||
|
||||
# Access adjacency to populate cache
|
||||
adj1 = bsp.adjacency
|
||||
n1 = len(adj1)
|
||||
|
||||
# Clear and re-split
|
||||
bsp.clear()
|
||||
bsp.split_recursive(depth=3, min_size=(10, 10), seed=123)
|
||||
|
||||
# Access adjacency again - should be rebuilt
|
||||
adj2 = bsp.adjacency
|
||||
n2 = len(adj2)
|
||||
|
||||
# Different seed/depth should give different results
|
||||
assert n2 > n1 or n2 != n1, "Cache should be invalidated after clear()"
|
||||
|
||||
print(" test_cache_invalidation: PASS")
|
||||
|
||||
def test_wall_tiles_on_boundary():
|
||||
"""Test that wall tiles are on the correct boundary"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
leaf0 = leaves[0] # Should be left side (x: 0-50)
|
||||
leaf1 = leaves[1] # Should be right side (x: 50-100)
|
||||
|
||||
# Get wall tiles from leaf0 to leaf1
|
||||
wall_tiles = leaf0.adjacent_tiles[1]
|
||||
|
||||
# Wall should be at x=49 (last column of leaf0) for leaf0
|
||||
for tile in wall_tiles:
|
||||
x, y = int(tile.x), int(tile.y)
|
||||
# Tile should be within leaf0's bounds
|
||||
assert x == 49, f"Wall tile x should be 49 (boundary), got {x}"
|
||||
assert 0 <= y < 50, f"Wall tile y should be in range 0-49, got {y}"
|
||||
|
||||
print(" test_wall_tiles_on_boundary: PASS")
|
||||
|
||||
def test_negative_indexing():
|
||||
"""Test that negative indices work for adjacency"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
|
||||
|
||||
adj = bsp.adjacency
|
||||
n = len(adj)
|
||||
|
||||
# adj[-1] should be same as adj[n-1]
|
||||
last_positive = adj[n-1]
|
||||
last_negative = adj[-1]
|
||||
|
||||
assert last_positive == last_negative, \
|
||||
f"Negative indexing failed: adj[-1]={last_negative}, adj[{n-1}]={last_positive}"
|
||||
|
||||
print(" test_negative_indexing: PASS")
|
||||
|
||||
def test_iteration():
|
||||
"""Test that adjacency can be iterated"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
|
||||
|
||||
adj = bsp.adjacency
|
||||
|
||||
# Should be iterable
|
||||
count = 0
|
||||
for neighbors in adj:
|
||||
assert isinstance(neighbors, tuple), f"Expected tuple, got {type(neighbors)}"
|
||||
count += 1
|
||||
|
||||
assert count == len(adj), f"Iteration count {count} != len {len(adj)}"
|
||||
|
||||
print(" test_iteration: PASS")
|
||||
|
||||
def test_keys_method():
|
||||
"""Test that adjacent_tiles.keys() works"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
bsp.split_once(horizontal=False, position=50)
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
leaf0 = leaves[0]
|
||||
|
||||
keys = leaf0.adjacent_tiles.keys()
|
||||
assert isinstance(keys, tuple), f"keys() should return tuple, got {type(keys)}"
|
||||
assert len(keys) > 0, "Should have at least one neighbor"
|
||||
assert 1 in keys, f"Leaf 1 should be in keys, got {keys}"
|
||||
|
||||
print(" test_keys_method: PASS")
|
||||
|
||||
def test_split_once_invalidation():
|
||||
"""Test that split_once invalidates adjacency cache and BSPNode references"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
|
||||
# First split - creates 2 leaves
|
||||
bsp.split_once(horizontal=False, position=50)
|
||||
|
||||
# Access adjacency to build cache
|
||||
adj1 = bsp.adjacency
|
||||
n1 = len(adj1)
|
||||
assert n1 == 2, f"Expected 2 leaves after first split, got {n1}"
|
||||
|
||||
# Get a reference to a leaf before second split
|
||||
old_leaf = list(bsp.leaves())[0]
|
||||
old_leaf_index = old_leaf.leaf_index
|
||||
|
||||
# Second split_once on the BSP (note: split_once always works on root)
|
||||
# After this, the tree structure changes, but old_leaf should be stale
|
||||
bsp.clear() # Clear and split fresh to get more leaves
|
||||
bsp.split_once(horizontal=False, position=50)
|
||||
bsp.split_once(horizontal=True, position=50) # Won't work - split_once only on root
|
||||
|
||||
# The old leaf reference should now be stale
|
||||
try:
|
||||
_ = old_leaf.leaf_index
|
||||
assert False, "Expected RuntimeError for stale BSPNode"
|
||||
except RuntimeError:
|
||||
pass # Expected - node is stale after clear()
|
||||
|
||||
# Access adjacency - should reflect new structure (2 leaves again)
|
||||
adj2 = bsp.adjacency
|
||||
n2 = len(adj2)
|
||||
assert n2 == 2, f"Expected 2 leaves after clear+split, got {n2}"
|
||||
|
||||
print(" test_split_once_invalidation: PASS")
|
||||
|
||||
def test_wall_tiles_perspective():
|
||||
"""Test that wall tiles are from the correct leaf's perspective"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
leaf0 = leaves[0] # Left side: x 0-50
|
||||
leaf1 = leaves[1] # Right side: x 50-100
|
||||
|
||||
# Get tiles from leaf0's perspective (should be at x=49, leaf0's edge)
|
||||
tiles_0_to_1 = leaf0.adjacent_tiles[1]
|
||||
for tile in tiles_0_to_1:
|
||||
assert int(tile.x) == 49, f"Leaf0->Leaf1 tile should be at x=49, got {tile.x}"
|
||||
|
||||
# Get tiles from leaf1's perspective (should be at x=50, leaf1's edge)
|
||||
tiles_1_to_0 = leaf1.adjacent_tiles[0]
|
||||
for tile in tiles_1_to_0:
|
||||
assert int(tile.x) == 50, f"Leaf1->Leaf0 tile should be at x=50, got {tile.x}"
|
||||
|
||||
print(" test_wall_tiles_perspective: PASS")
|
||||
|
||||
def test_get_leaf():
|
||||
"""Test bsp.get_leaf(index) method"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
|
||||
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
n = len(leaves)
|
||||
|
||||
# Test positive indices
|
||||
for i in range(n):
|
||||
leaf = bsp.get_leaf(i)
|
||||
assert leaf.leaf_index == i, f"get_leaf({i}) returned leaf with index {leaf.leaf_index}"
|
||||
|
||||
# Test negative index
|
||||
last_leaf = bsp.get_leaf(-1)
|
||||
assert last_leaf.leaf_index == n - 1, f"get_leaf(-1) should return leaf {n-1}, got {last_leaf.leaf_index}"
|
||||
|
||||
# Test out of range
|
||||
try:
|
||||
bsp.get_leaf(n)
|
||||
assert False, "Expected IndexError for out-of-range index"
|
||||
except IndexError:
|
||||
pass # Expected
|
||||
|
||||
print(" test_get_leaf: PASS")
|
||||
|
||||
def test_contains_operator():
|
||||
"""Test 'in' operator for adjacent_tiles"""
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
|
||||
bsp.split_once(horizontal=False, position=50)
|
||||
|
||||
leaves = list(bsp.leaves())
|
||||
leaf0 = leaves[0]
|
||||
|
||||
# Leaf 1 should be adjacent to leaf 0
|
||||
assert 1 in leaf0.adjacent_tiles, "1 should be in leaf0.adjacent_tiles"
|
||||
|
||||
# Some arbitrary index should not be (assuming only 2 leaves)
|
||||
assert 5 not in leaf0.adjacent_tiles, "5 should not be in leaf0.adjacent_tiles"
|
||||
|
||||
print(" test_contains_operator: PASS")
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all adjacency tests"""
|
||||
print("Running BSP adjacency tests (#210)...")
|
||||
|
||||
test_adjacency_basic()
|
||||
test_leaf_indexing()
|
||||
test_adjacency_symmetry()
|
||||
test_adjacent_tiles_basic()
|
||||
test_adjacent_tiles_keyerror()
|
||||
test_cache_invalidation()
|
||||
test_wall_tiles_on_boundary()
|
||||
test_negative_indexing()
|
||||
test_iteration()
|
||||
test_keys_method()
|
||||
test_split_once_invalidation()
|
||||
test_wall_tiles_perspective()
|
||||
test_get_leaf()
|
||||
test_contains_operator()
|
||||
|
||||
print("\nAll BSP adjacency tests passed!")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_all_tests())
|
||||
Loading…
Add table
Add a link
Reference in a new issue