diff --git a/CHANGELOG.md b/CHANGELOG.md index b8281650..975d2bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Fixed +- Copying or pickling Perlin and Simplex noise no longer advances a shared random generator while preparing an unused wavelet tile. - `tcod.image.Image.rotate90` updates the width and height of rectangular images, keeping NumPy array conversions consistent with the rotated pixels. - Adding a root to an already resolved `tcod.path.Pathfinder` resets its predecessor only when its value improves the current distance. This prevents cyclic paths while preserving routes to cheaper roots. - Lazy pathfinder queries reconsider previously visited goals after adding a root or lowering a root's distance. diff --git a/tcod/noise.py b/tcod/noise.py index 6d7c641a..6ff56c49 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -357,9 +357,18 @@ def __getstate__(self) -> dict[str, Any]: if self.dimensions < 4 and self.noise_c.waveletTileData == ffi.NULL: # noqa: PLR2004 # Trigger a side effect of wavelet, so that copies will be synced. saved_algo = self.algorithm + saved_random = self.noise_c.rand + wavelet_random = saved_random + if saved_algo != tcod.constants.NOISE_WAVELET: + # Preparing an unused algorithm must not advance a shared RNG. + wavelet_random = ffi.gc(lib.TCOD_random_save(saved_random), lib.TCOD_random_delete) + self.noise_c.rand = wavelet_random self.algorithm = tcod.constants.NOISE_WAVELET - self.get_point() - self.algorithm = saved_algo + try: + self.get_point() + finally: + self.algorithm = saved_algo + self.noise_c.rand = saved_random waveletTileData = None # noqa: N806 if self.noise_c.waveletTileData != ffi.NULL: diff --git a/tests/test_noise.py b/tests/test_noise.py index 28825328..95515b16 100644 --- a/tests/test_noise.py +++ b/tests/test_noise.py @@ -2,6 +2,7 @@ import copy import pickle +from collections.abc import Callable import numpy as np import pytest @@ -103,3 +104,20 @@ def test_noise_copy() -> None: def test_noise_grid() -> None: tcod.noise.grid((2, 2), scale=2) # Check int scale + + +@pytest.mark.parametrize("algorithm", [tcod.noise.Algorithm.PERLIN, tcod.noise.Algorithm.SIMPLEX]) +@pytest.mark.parametrize("rng_algorithm", [tcod.random.MERSENNE_TWISTER, tcod.random.MULTIPLY_WITH_CARRY]) +@pytest.mark.parametrize("snapshot", [copy.copy, copy.deepcopy, lambda n: pickle.loads(pickle.dumps(n))]) +def test_noise_snapshot_preserves_shared_random( + algorithm: int, rng_algorithm: int, snapshot: Callable[[tcod.noise.Noise], tcod.noise.Noise] +) -> None: + rng = tcod.random.Random(rng_algorithm, seed=42) + noise = tcod.noise.Noise(2, algorithm=algorithm, seed=rng) + expected = copy.deepcopy(rng) + noise_copy = snapshot(noise) + assert [rng.randint(0, 100) for _ in range(8)] == [expected.randint(0, 100) for _ in range(8)] + assert noise.algorithm == algorithm + # The lazy wavelet tile remains synchronized if this algorithm is selected later. + noise.algorithm = noise_copy.algorithm = tcod.noise.Algorithm.WAVELET + np.testing.assert_array_equal(noise.sample_ogrid(np.ogrid[:3, :4]), noise_copy.sample_ogrid(np.ogrid[:3, :4]))