403 lines
16 KiB
Go
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])
|
|
}
|
|
}
|
|
}
|