Extracted from the T-1194 stipple, which was the first of a family: graffiti placement, cracks in textures, drifting cloud cover — presentation decisions that must look the same when the player returns to a place, and which the simulation has no opinion about and should not be burdened with. THE LINE IT DRAWS. It answers "how is this drawn", never "what is here". A cell's biome, a settlement's position, whether a wall exists — those are world data, derived once by the server and sampled everywhere (D-255(f) mechanism B), and the player eventually stands on them; inventing those here would put the map and the ground in disagreement. Stated on the class so the next consumer does not have to re-derive it: if the answer changes what is THERE it is not a ScatterField question; if it only changes how it is DRAWN, it is. Bit-identity with the server's Rust noise is explicitly NOT a requirement (Jeroen: "a seed is a seed and the functional intended outcome is repetition here"). Nothing here is compared against a server value or round-tripped through a save, so the contract is stability across sessions, not agreement across languages — which is precisely why paint belongs on this side: it buys visual density with no cross-language determinism burden. Seeded from GameState.world_seed, so two playthroughs scatter differently and one playthrough is stable forever. API: domain() resolves a name to a salt ONCE (the first consumer runs ~700,000 times per canvas rebuild, so the hot calls take an int, never a string); value/chance/pick/jitter for discrete marks; smooth() for continuous fields like cloud cover; an optional time axis for animation. Domains keep consumers uncorrelated — without them graffiti and cracks at the same wall coordinate would mark identical spots and read as one artefact. The tests pin the CONTRACT, not the numbers — freezing outputs would make any future improvement to the mixer a breaking change for no gain. They caught a real defect immediately: (-x, -y) collided with (x, y), because negated coordinates produce negated products and the sign-bit mask folded the pair together, mirroring every mark west and south of the origin onto its north-east counterpart. Not an edge case — the descent ladder's own anchor sits at y = -5,675,959. Fixed by zigzag-encoding coordinates before mixing. 1853 client tests, 0 failed (15 new). Global capture re-verified unchanged after migrating the stipple onto the service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.9 KiB
GDScript
194 lines
7.9 KiB
GDScript
## Tests for ScatterField — the client-side deterministic scatter service.
|
|
##
|
|
## What is worth pinning here is the CONTRACT, not the numbers: the service
|
|
## exists so paint looks the same when the player comes back, and so that two
|
|
## consumers scattering over the same coordinates do not draw on top of each
|
|
## other. Both of those are properties, and both fail silently if they break —
|
|
## a correlated domain reads as "one slightly odd texture", not as a bug.
|
|
##
|
|
## Deliberately NOT pinned: the specific values. Bit-identity with the server's
|
|
## Rust noise is explicitly not a requirement (Jeroen, 2026-08-07 — "a seed is a
|
|
## seed and the functional intended outcome is repetition here"), and freezing
|
|
## the outputs here would turn any future improvement to the mixer into a
|
|
## test-breaking change for no gain.
|
|
class_name TestScatterField
|
|
extends GdUnitTestSuite
|
|
|
|
const ScatterField := preload("res://scripts/scatter_field.gd")
|
|
|
|
|
|
# =============================================================================
|
|
# The core contract: same question, same answer
|
|
# =============================================================================
|
|
|
|
|
|
func test_same_position_repeats_forever() -> void:
|
|
var salt: int = ScatterField.domain(&"test/repeat")
|
|
var first: float = ScatterField.value(salt, 12, 34)
|
|
for _i in range(50):
|
|
assert_float(ScatterField.value(salt, 12, 34)).override_failure_message(
|
|
"a position must answer identically every time — this is the whole service"
|
|
).is_equal(first)
|
|
|
|
|
|
func test_neighbouring_positions_decorrelate() -> void:
|
|
# Consumers walk coordinates in order, so adjacent inputs producing adjacent
|
|
# outputs would render as a gradient or a moire rather than as scatter.
|
|
var salt: int = ScatterField.domain(&"test/decorrelate")
|
|
var a: float = ScatterField.value(salt, 100, 100)
|
|
var b: float = ScatterField.value(salt, 101, 100)
|
|
var c: float = ScatterField.value(salt, 100, 101)
|
|
assert_float(absf(a - b)).is_greater(0.001)
|
|
assert_float(absf(a - c)).is_greater(0.001)
|
|
|
|
|
|
func test_values_stay_in_unit_range() -> void:
|
|
var salt: int = ScatterField.domain(&"test/range")
|
|
for i in range(500):
|
|
var v: float = ScatterField.value(salt, i, i * 7)
|
|
assert_float(v).is_between(0.0, 1.0)
|
|
|
|
|
|
func test_negative_coordinates_are_supported() -> void:
|
|
# World coordinates go negative — a consumer painting west or south of the
|
|
# origin must not crash or fold onto its mirror position.
|
|
var salt: int = ScatterField.domain(&"test/negative")
|
|
var neg: float = ScatterField.value(salt, -500, -900)
|
|
assert_float(neg).is_between(0.0, 1.0)
|
|
assert_float(neg).override_failure_message(
|
|
"(-x, -y) must not collide with (x, y) — that would mirror all paint about the origin"
|
|
).is_not_equal(ScatterField.value(salt, 500, 900))
|
|
|
|
|
|
# =============================================================================
|
|
# Domain separation — why graffiti and cracks do not land together
|
|
# =============================================================================
|
|
|
|
|
|
func test_domains_are_uncorrelated_at_the_same_position() -> void:
|
|
var graffiti: int = ScatterField.domain(&"world/graffiti")
|
|
var cracks: int = ScatterField.domain(&"world/cracks")
|
|
var agree: int = 0
|
|
for i in range(200):
|
|
if is_equal_approx(ScatterField.value(graffiti, i, 0), ScatterField.value(cracks, i, 0)):
|
|
agree += 1
|
|
assert_int(agree).override_failure_message(
|
|
"two domains agreeing at the same coordinates means every consumer marks the "
|
|
+ "same spots — the artefacts would stack and read as one"
|
|
).is_less(3)
|
|
|
|
|
|
func test_domain_resolution_is_stable_and_cached() -> void:
|
|
assert_int(ScatterField.domain(&"test/stable")).is_equal(
|
|
ScatterField.domain(&"test/stable")
|
|
)
|
|
assert_int(ScatterField.domain(&"test/a")).is_not_equal(ScatterField.domain(&"test/b"))
|
|
|
|
|
|
# =============================================================================
|
|
# The derived helpers
|
|
# =============================================================================
|
|
|
|
|
|
func test_chance_honours_its_probability() -> void:
|
|
var salt: int = ScatterField.domain(&"test/chance")
|
|
var hits: int = 0
|
|
var n: int = 4000
|
|
for i in range(n):
|
|
if ScatterField.chance(salt, i % 64, i / 64, 0.25):
|
|
hits += 1
|
|
var rate: float = float(hits) / float(n)
|
|
assert_float(rate).override_failure_message(
|
|
"p=0.25 produced %.3f — a biased field makes every density constant a lie" % rate
|
|
).is_between(0.21, 0.29)
|
|
|
|
|
|
func test_chance_extremes_are_absolute() -> void:
|
|
var salt: int = ScatterField.domain(&"test/extremes")
|
|
for i in range(200):
|
|
assert_bool(ScatterField.chance(salt, i, 0, 0.0)).is_false()
|
|
assert_bool(ScatterField.chance(salt, i, 0, 1.0)).is_true()
|
|
|
|
|
|
func test_pick_covers_its_whole_range() -> void:
|
|
# A variant chooser that never returns some of its options silently reduces
|
|
# the asset set — the sprite exists, it just never appears.
|
|
var salt: int = ScatterField.domain(&"test/pick")
|
|
var seen: Dictionary = {}
|
|
for i in range(600):
|
|
seen[ScatterField.pick(salt, i, 0, 4)] = true
|
|
assert_int(seen.size()).is_equal(4)
|
|
for i in range(50):
|
|
assert_int(ScatterField.pick(salt, i, 0, 4)).is_between(0, 3)
|
|
|
|
|
|
func test_pick_degenerate_counts_are_safe() -> void:
|
|
var salt: int = ScatterField.domain(&"test/pick_degenerate")
|
|
assert_int(ScatterField.pick(salt, 5, 5, 1)).is_equal(0)
|
|
assert_int(ScatterField.pick(salt, 5, 5, 0)).is_equal(0)
|
|
|
|
|
|
func test_jitter_is_centred_and_bounded() -> void:
|
|
var salt: int = ScatterField.domain(&"test/jitter")
|
|
var sum := Vector2.ZERO
|
|
var n: int = 2000
|
|
for i in range(n):
|
|
var j: Vector2 = ScatterField.jitter(salt, i % 50, i / 50)
|
|
assert_float(j.x).is_between(-0.5, 0.5)
|
|
assert_float(j.y).is_between(-0.5, 0.5)
|
|
sum += j
|
|
# Both axes must be independent streams; a shared one puts every mark on the
|
|
# diagonal, which reads as a hatch rather than as scatter.
|
|
assert_float(absf(sum.x / float(n))).is_less(0.05)
|
|
assert_float(absf(sum.y / float(n))).is_less(0.05)
|
|
|
|
|
|
# =============================================================================
|
|
# The time axis — animation and layering
|
|
# =============================================================================
|
|
|
|
|
|
func test_time_step_changes_the_field() -> void:
|
|
var salt: int = ScatterField.domain(&"sky/cloud_cover")
|
|
var t0: float = ScatterField.value(salt, 7, 7, 0)
|
|
var t1: float = ScatterField.value(salt, 7, 7, 1)
|
|
assert_float(absf(t0 - t1)).override_failure_message(
|
|
"advancing t must move the field, or cloud cover cannot animate"
|
|
).is_greater(0.001)
|
|
# ...and each step is itself stable, so an animation loops rather than boils.
|
|
assert_float(ScatterField.value(salt, 7, 7, 1)).is_equal(t1)
|
|
|
|
|
|
# =============================================================================
|
|
# smooth() — the continuous field
|
|
# =============================================================================
|
|
|
|
|
|
func test_smooth_is_continuous_between_lattice_points() -> void:
|
|
# The point of smooth() over value(): neighbouring samples must be CLOSE, or
|
|
# cloud cover is speckle instead of cloud.
|
|
var salt: int = ScatterField.domain(&"test/smooth")
|
|
var prev: float = ScatterField.smooth(salt, 0.0, 0.0, 64.0)
|
|
for i in range(1, 200):
|
|
var v: float = ScatterField.smooth(salt, float(i), 0.0, 64.0)
|
|
assert_float(absf(v - prev)).override_failure_message(
|
|
"smooth() jumped %.3f in one world unit at wavelength 64 — not a field" % absf(v - prev)
|
|
).is_less(0.2)
|
|
prev = v
|
|
|
|
|
|
func test_smooth_stays_in_unit_range_and_repeats() -> void:
|
|
var salt: int = ScatterField.domain(&"test/smooth_range")
|
|
for i in range(300):
|
|
var v: float = ScatterField.smooth(salt, float(i) * 3.7, float(i) * 1.3, 32.0)
|
|
assert_float(v).is_between(0.0, 1.0)
|
|
assert_float(ScatterField.smooth(salt, 12.5, 8.25, 32.0)).is_equal(
|
|
ScatterField.smooth(salt, 12.5, 8.25, 32.0)
|
|
)
|
|
|
|
|
|
func test_smooth_degenerate_wavelength_does_not_divide_by_zero() -> void:
|
|
var salt: int = ScatterField.domain(&"test/smooth_zero")
|
|
assert_float(ScatterField.smooth(salt, 4.0, 4.0, 0.0)).is_between(0.0, 1.0)
|
|
assert_float(ScatterField.smooth(salt, 4.0, 4.0, -8.0)).is_between(0.0, 1.0)
|