Files
2026-09-25 17:02:24 +03:00

403 lines
16 KiB
Go

package detail
import (
"math"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
const testCellM = 2.0
// coastalCfg is the manifest's own block, so the tests fail when a default moves rather than measuring a copy
// of it that nothing ships.
func coastalCfg() (manifest.CoastDetail, manifest.Coast) {
m := manifest.Defaults()
return m.Pipeline.CoastDetail, m.Pipeline.Coast
}
func coastPlanet(w, h int) world.Planet {
return world.Planet{CellM: testCellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * testCellM}
}
// straightCoast is a world cut in half: land to the left of shoreM, sea to the right. The land rises to backM
// over one surf reach and then holds, so the backshore window the pass measures in is exactly backM and the
// beach-or-cliff decision in a test is the number the test set.
//
// A straight coast rather than an island on purpose: the profile is then one dimensional, so "what did the
// pass do" is a column that can be read off and compared against the arithmetic it is meant to be.
func straightCoast(w, h int, shoreM, backM, reachM, seaDepthM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
if inland >= 0 {
land[i] = true
t := inland / reachM
if t > 1 {
t = 1
}
f.Data[i] = float32(backM * t * t * (3 - 2*t))
} else {
f.Data[i] = float32(-seaDepthM)
}
}
}
return f, land
}
func runCoastal(t *testing.T, f *field.Field, land []bool, p world.Planet, x0, y0 int, backM float64) CoastalStats {
t.Helper()
cfg, surf := coastalCfg()
return RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: x0, Y0: y0, W: f.W, H: f.H},
PeriodM: 1000, SeaLevelM: 0,
})
}
// Rule 1, for this pass: everything is keyed on absolute world position - the crenulation lattice through
// noise.WorldUV, the distance through a transform whose seeds are the same cells - so a window cut out of a
// bigger world and run on its own comes back bit-identical inside its margin.
//
// This is the test for the mistake the rule exists for: a noise field indexed by grid index instead of world
// position looks perfect on any one tile and puts a seam down every tile boundary. Measured by breaking it -
// passing a zero origin to WorldUV moves the interior by up to 3.6 m.
//
// It is *not* the test for the margin being big enough; the coast here is in the middle of the window, so the
// answer would be the same with no margin at all. TestThePassFitsInsideTheTileMargin is that one.
func TestATileInteriorIsWhatOneWholeRunWouldHaveGiven(t *testing.T) {
const w, h = 512, 192
p := coastPlanet(w, h)
whole, land := straightCoast(w, h, 420, 40, 110, 6)
runCoastal(t, whole, land, p, 0, 0, 40)
// The same world, cut out with a margin and run on its own. 130 cells is 260 m, which is past the pass's
// own outer limit of two surf reaches.
const margin = 130
const cx0, cw = 160, 192
cut := field.New(cw+2*margin, h, testCellM)
cutLand := make([]bool, len(cut.Data))
src, srcLand := straightCoast(w, h, 420, 40, 110, 6)
for y := 0; y < h; y++ {
for x := 0; x < cut.W; x++ {
sx := cx0 - margin + x
cut.Data[y*cut.W+x] = src.Data[y*w+sx]
cutLand[y*cut.W+x] = srcLand[y*w+sx]
}
}
runCoastal(t, cut, cutLand, p, cx0-margin, 0, 40)
var worst float64
for y := 0; y < h; y++ {
for x := 0; x < cw; x++ {
a := whole.Data[y*w+cx0+x]
b := cut.Data[y*cut.W+margin+x]
if d := math.Abs(float64(a) - float64(b)); d > worst {
worst = d
}
}
}
if worst != 0 {
t.Fatalf("a tile's interior differs from the whole run by up to %g m; every hash and lattice in this "+
"pass is supposed to be keyed on world position", worst)
}
}
// The cliff branch only cuts, so it owes an apron. This is the one hard conservation statement in the pass:
// what comes off the face is what lands at its foot, per stretch of shore rather than per tile, so the debris
// under a cliff is that cliff's debris.
func TestTheScreeIsExactlyWhatTheCliffLost(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 400, 60, 110, 6)
st := runCoastal(t, f, land, p, 0, 0, 60)
if st.CutM3 <= 0 {
t.Fatalf("a 60 m backshore cut nothing off its face; cliff fraction %.2f", st.CliffFrac)
}
if st.CliffFrac < 0.99 {
t.Fatalf("a 60 m backshore is %.0f%% cliff, not a cliff coast", st.CliffFrac*100)
}
// Float32 heights, so the tolerance is the accumulation of a few million of them rather than zero.
if rel := math.Abs(st.ScreeM3-st.CutM3) / st.CutM3; rel > 1e-9 {
t.Fatalf("the face lost %.3f m3 and the apron gained %.3f m3, a relative gap of %g",
st.CutM3, st.ScreeM3, rel)
}
}
// A beach coast and a cliff coast are the same code with one number changed, and the number is the height of
// the land behind the shore. This checks the two come out as different landforms rather than as the same one
// scaled: a berm above the waterline on the beach, and no berm at all on the cliff.
func TestTheBackshoreDecidesBetweenABeachAndACliff(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// The swash zone: the strip just inland of the waterline. A berm is ground *standing* above the water
// there, so the measurement is a height and not a change - the first version of this measured how much
// the pass raised the ground and read 6 m on a beach, all of it the foreshore being filled up from the
// flat sea floor the fixture starts with. What was being measured was the fixture.
crest := func(f *field.Field) float64 {
var top float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
inland := 400 - float64(x)*testCellM
if inland < 0 || inland > float64(cfg.BermBackM) {
continue
}
if v := float64(f.Data[y*w+x]); v > top {
top = v
}
}
}
return top
}
beach, beachLand := straightCoast(w, h, 400, 3, 110, 6)
beachStats := runCoastal(t, beach, beachLand, p, 0, 0, 3)
cliff, cliffLand := straightCoast(w, h, 400, 60, 110, 6)
cliffStats := runCoastal(t, cliff, cliffLand, p, 0, 0, 60)
if beachStats.CliffFrac > 0.01 {
t.Errorf("a 3 m backshore came out %.0f%% cliff", beachStats.CliffFrac*100)
}
if cliffStats.CliffFrac < 0.99 {
t.Errorf("a 60 m backshore came out only %.0f%% cliff", cliffStats.CliffFrac*100)
}
// With no exposure field every shore is treated as fully exposed, so the berm stands at the manifest's
// full height.
gotBerm := crest(beach)
if want := surf.BermM; gotBerm < want*0.8 || gotBerm > want*1.2 {
t.Errorf("the beach's swash zone tops out at %.2f m; a berm should stand about %.2f", gotBerm, want)
}
// The cliff coast has a shore platform there instead, which runs up at the platform grade and nothing
// more: a cliff does not get a berm, it gets the rock the surf planed.
gotPlatform := crest(cliff)
if want := surf.PlatformGrade * cfg.BermBackM; gotPlatform > want*1.5 {
t.Errorf("the cliff's swash zone tops out at %.2f m; the platform should reach about %.2f",
gotPlatform, want)
}
if gotPlatform >= gotBerm {
t.Errorf("the cliff coast (%.2f m) stands as high in the swash zone as the beach (%.2f m); the two "+
"branches are not producing different landforms", gotPlatform, gotBerm)
}
}
// The claim that lets the pass run per tile at all: it never reaches further from the waterline than the tile
// margin, so a tile's margin holds everything its interior needed.
//
// The margin is the droplets' - three lifetimes, 244 m at the defaults - and this pass has to fit inside a
// number that was measured for something else. Two surf reaches is its own hard limit, and it is a limit
// rather than a consequence: past it a cell has no stretch of shore to belong to at all.
//
// The test asserts both ends. Past the margin, nothing may move; and something must move a good way out, or
// the test would pass just as well on a pass that did nothing.
func TestThePassFitsInsideTheTileMargin(t *testing.T) {
const w, h = 512, 96
p := coastPlanet(w, h)
m := manifest.Defaults()
marginM := float64(MarginCells(m.Pipeline.Particle)) * testCellM
for _, backM := range []float64{3, 40, 300, 600} {
f, land := straightCoast(w, h, 500, backM, 110, 6)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, backM)
var reachedM float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if f.Data[i] == before.Data[i] {
continue
}
if d := math.Abs(500 - float64(x)*testCellM); d > reachedM {
reachedM = d
}
}
}
if reachedM > marginM {
t.Errorf("backshore %.0f m: the pass reached %.0f m from the waterline, past the %.0f m tile "+
"margin it has to fit inside", backM, reachedM, marginM)
}
if reachedM < 40 {
t.Errorf("backshore %.0f m: the pass only reached %.0f m, which is not a shore profile",
backM, reachedM)
}
t.Logf("backshore %3.0f m: reached %3.0f m of the %.0f m margin", backM, reachedM, marginM)
}
}
// Dean's profile is the one piece of published geomorphology in this pass, so it is worth checking that what
// comes out is actually it rather than something that merely slopes the right way. Away from the crenulation
// and inside the full-weight strip, the depth under water must be A*x^(2/3).
func TestTheForeshoreIsDeansProfile(t *testing.T) {
const w, h = 320, 64
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// Shallow water on purpose. A beach may lay at most BeachFillM of sediment on what is already there, so a
// fixture with a deep flat floor would measure the cap rather than the curve - which is what the first
// version of this did, at 40 m, and it read a flat profile 3 m above the floor. At 3 m the equilibrium
// curve sits above the floor by less than the cap everywhere it is sampled.
f, land := straightCoast(w, h, 300, 3, 110, 3)
runCoastal(t, f, land, p, 0, 0, 3)
// One row, and the crenulation read off the pass's own noise by inverting the profile at a known depth
// would be circular - so instead the check is against the *shape*: the ratio of depths at two offsets
// must be (x1/x2)^(2/3) whatever the crenulation shifted them by, and that is what is asserted.
y := h / 2
depthAt := func(offsetM float64) float64 {
x := int((300 + offsetM) / testCellM)
return -float64(f.Data[y*w+x])
}
d1, d2 := depthAt(20), depthAt(45)
if d1 <= 0 || d2 <= d1 {
t.Fatalf("the foreshore is not going down: %.2f m at 20 m out, %.2f m at 45 m", d1, d2)
}
// Solve for the shift the crenulation applied, then check A.
// d1 = A*(20+s)^(2/3), d2 = A*(45+s)^(2/3)
var best, bestErr = 0.0, math.Inf(1)
for s := -cfg.CrenulationM; s <= cfg.CrenulationM; s += 0.01 {
want := math.Pow((45+s)/(20+s), 2.0/3.0)
if e := math.Abs(d2/d1 - want); e < bestErr {
best, bestErr = s, e
}
}
if bestErr > 0.02 {
t.Fatalf("the two depths %.3f and %.3f are not in a 2/3-power ratio at any crenulation inside "+
"+/-%.0f m (best miss %.3f)", d1, d2, cfg.CrenulationM, bestErr)
}
gotA := d1 / math.Pow(20+best, 2.0/3.0)
if math.Abs(gotA-cfg.DeanA) > 0.01 {
t.Fatalf("Dean's A came out %.3f against the manifest's %.3f (crenulation %.2f m)",
gotA, cfg.DeanA, best)
}
_ = surf
}
// speckledCoast is a coastal plain: land rising at one in a hundred, with a little roughness on it. That is
// enough to make the land mask a forty-metre band of speckle rather than a line, which is what a real one is
// - measured on region 11 of the first painted planet, where the shore wandered eighteen cells between rows
// three apart and a row crossed sea level three times.
func speckledCoast(w, h int, shoreM, grade, roughM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
// A hash of the cell, so the roughness is the same every run and has no structure in it.
k := uint32(x*374761393+y*668265263) * 2246822519
k ^= k >> 13
u := float64(k%10007)/10007.0 - 0.5
v := grade*inland + roughM*u
f.Data[i] = float32(v)
land[i] = v > 0
}
}
return f, land
}
// What a coastal plain does to a shoreline, and the reason the signed distance is smoothed before the profile
// is measured from it.
//
// The pass rebuilds the surface as a monotonic function of that distance, so its output crosses sea level
// once along any line across the shore however ragged the input was. Without the smoothing it instead builds
// a separate berm on every island in the speckle, which is what the first run of the pass did: a string of
// beads down the whole coast.
func TestACoastalPlainComesOutWithOneShorelineAndNotABeadedOne(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := speckledCoast(w, h, 400, 0.01, 0.30)
crossings := func(g *field.Field) float64 {
total := 0
for y := 0; y < h; y++ {
n := 0
for x := 1; x < w; x++ {
a, b := g.Data[y*w+x-1], g.Data[y*w+x]
if (a <= 0) != (b <= 0) {
n++
}
}
total += n
}
return float64(total) / float64(h)
}
before := crossings(f)
if before < 3 {
t.Fatalf("the fixture is not speckled: %.1f sea-level crossings a row", before)
}
runCoastal(t, f, land, p, 0, 0, 4)
after := crossings(f)
if after > 1.05 {
t.Errorf("the shore came out with %.2f sea-level crossings a row (%.1f before); a shoreline crosses "+
"once, and more than that is a bead on the beach for every island in the mask", after, before)
}
t.Logf("sea-level crossings a row: %.1f before, %.2f after", before, after)
}
// A beach is a veneer of sediment and not a landform that fills a fjord.
//
// The equilibrium profile is a target *depth*, so on a shore with forty metres of water a hundred metres off
// it - a drowned valley, which is an ordinary thing on a real coast - an uncapped beach branch invents
// thirty-seven metres of sand to bring the floor up to the curve. Capped, the beach lays a few metres on
// whatever is there and runs out where the water gets deep, which is what a steep-to shore is.
func TestABeachDoesNotFillADrownedValley(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, _ := coastalCfg()
f, land := straightCoast(w, h, 400, 3, 110, 40)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, 3)
var worst float64
for i := range f.Data {
if d := float64(f.Data[i]) - float64(before.Data[i]); d > worst {
worst = d
}
}
if worst > cfg.BeachFillM+0.01 {
t.Fatalf("the beach laid %.2f m of sediment where the cap is %.2f; a shore with deep water close in "+
"is a steep-to shore, not a bay to be filled", worst, cfg.BeachFillM)
}
if worst < cfg.BeachFillM*0.5 {
t.Fatalf("the beach laid only %.2f m; the fixture is meant to press against the %.2f m cap",
worst, cfg.BeachFillM)
}
}
// The pass is off when the manifest says so, and off means nothing at all rather than a cheaper version of
// itself. Worth a test because it is the switch somebody reaches for when a coast looks wrong, and a switch
// that half works is worse than no switch.
func TestTheSwitchTurnsItOff(t *testing.T) {
const w, h = 128, 64
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 150, 40, 110, 6)
before := f.Clone()
cfg, surf := coastalCfg()
cfg.Enabled = false
st := RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: 0, Y0: 0, W: w, H: h},
PeriodM: 1000, SeaLevelM: 0,
})
if st.ShoreCells != 0 {
t.Errorf("a disabled pass reported %d shore cells", st.ShoreCells)
}
for i := range f.Data {
if f.Data[i] != before.Data[i] {
t.Fatalf("a disabled pass moved cell %d from %g to %g", i, before.Data[i], f.Data[i])
}
}
}