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]) } } }