package check import ( "runtime" "testing" "salty/terrain/internal/fluvial" "salty/terrain/internal/manifest" "salty/terrain/internal/region" "salty/terrain/internal/template" "salty/terrain/internal/thermal" "salty/terrain/internal/uplift" "salty/terrain/internal/world" ) const planetLegend = `{"classes":[ {"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":400}, {"name":"plain","rgb":[150,200,100],"uplift_mm_yr":0.08,"k_mult":1.0}, {"name":"range","rgb":[60,160,100],"uplift_mm_yr":0.9,"k_mult":0.6} ]}` // syntheticPlanet paints a small world with three landmasses, one of them across the seam, and returns it // classified and projected. It is the smallest thing that exercises everything a real bake does: a cylinder, // several regions, a seam, and two uplift classes. func syntheticPlanet(t *testing.T, seed int64) (*manifest.Manifest, *template.Map, *region.Partition) { t.Helper() lg, err := template.Parse([]byte(planetLegend)) if err != nil { t.Fatal(err) } const w, paintH, pad = 128, 64, 6 p := world.Planet{CellM: 40, W: w, H: paintH + 2*pad, PadY: pad, NoisePeriodM: w * 40} if err := p.Validate(); err != nil { t.Fatal(err) } sea := uint8(lg.Index("sea")) plain := uint8(lg.Index("plain")) rng := uint8(lg.Index("range")) m := &template.Map{P: p, L: lg, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)} for i := range m.Class { m.Class[i], m.Sea[i] = sea, true } put := func(x0, y0, w0, h0 int, c uint8) { for y := y0; y < y0+h0; y++ { for x := x0; x < x0+w0; x++ { i := (y+pad)*p.W + p.WrapX(x) m.Class[i], m.Sea[i] = c, false } } } put(20, 10, 30, 24, plain) // a plain put(30, 16, 12, 10, rng) // with a range in it put(70, 30, 22, 20, rng) // a mountainous island put(-4, 44, 10, 12, plain) // and one across the seam part, err := region.Build(m, 4, 4) if err != nil { t.Fatal(err) } if len(part.Regions) < 3 { t.Fatalf("got %d regions, want at least 3", len(part.Regions)) } seam := false for _, r := range part.Regions { seam = seam || r.Seam } if !seam { t.Fatal("no region straddles the seam; the test is not testing what it claims") } man := manifest.Defaults() man.Source.Seed = seed man.Planet = &manifest.Planet{UpliftVariation: 0.3} return man, m, part } // solvePlanet runs the whole painted path: cut each region, build its painted geology, solve it, composite // the land back. It is deliberately the same sequence internal/planet uses. func solvePlanet(t *testing.T, seed int64, steps int) []float32 { t.Helper() man, m, part := syntheticPlanet(t, seed) rates, ks := m.L.Rates(), m.L.Erodibilities() out := make([]float32, m.P.W*m.P.H) params := fluvial.Params{ K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1, TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2, CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24, } for _, rg := range part.Regions { class, land := part.Cut(m, rg) up := uplift.FromTemplate(uplift.Paint{ Frame: rg.Frame, Class: class, Land: land, Rates: rates, Ks: ks, Variation: man.Planet.UpliftVariation, }, man) h := up.Height.Clone() g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base) g.SetSeed(man.Source.Seed) g.SetFrame(rg.Frame) g.SetElevationRange(-2000, 4000) g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil) part.Composite(out, m, rg, h.Data) } return out } // The painted path's half of cross-cutting rule 12. The square canvas already has this assertion; a planet // adds three ways to break it that the square canvas cannot reach - the classifier's parallel reduction, the // region flood, and regions solved several at a time - so it gets its own. func TestPaintedPlanetIsDeterministicAcrossGOMAXPROCS(t *testing.T) { was := runtime.GOMAXPROCS(1) defer runtime.GOMAXPROCS(was) var want string for _, procs := range []int{1, 2, 4, 8, 16} { runtime.GOMAXPROCS(procs) got := hash(solvePlanet(t, 7, 60)) if want == "" { want = got continue } if got != want { t.Fatalf("GOMAXPROCS %d gives %s, GOMAXPROCS 1 gives %s", procs, got, want) } } } func TestSameSeedSamePlanet(t *testing.T) { a := hash(solvePlanet(t, 11, 40)) b := hash(solvePlanet(t, 11, 40)) if a != b { t.Fatalf("two runs of the same seed differ: %s and %s", a, b) } if c := hash(solvePlanet(t, 12, 40)); c == a { t.Fatal("two different seeds give the same planet") } } // The invariant the whole per-landmass decomposition rests on, asserted directly. // // Solving a landmass in a box of its own is only the same answer as solving the planet whole because ocean // cells are held fixed at sea level and nothing in the solve can move them: ComputeReceivers makes every // outlet its own receiver, so no flow path crosses water, and StreamPower, both diffusions, the repose clamp // and thermal all skip a fixed cell. If that ever stopped being true, regions would start lying to each // other and nothing else in the suite would say so. func TestOceanCellsAreUntouchedByTheSolve(t *testing.T) { man, m, part := syntheticPlanet(t, 7) rates, ks := m.L.Rates(), m.L.Erodibilities() params := fluvial.Params{ K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: 80, Diffusion: 0.02, FillEvery: 1, TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2, CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24, } checked := 0 for _, rg := range part.Regions { class, land := part.Cut(m, rg) up := uplift.FromTemplate(uplift.Paint{ Frame: rg.Frame, Class: class, Land: land, Rates: rates, Ks: ks, Variation: 0.3, }, man) h := up.Height.Clone() g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base) g.SetSeed(man.Source.Seed) g.SetFrame(rg.Frame) g.SetElevationRange(-2000, 4000) g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil) for i, isBase := range up.Base { if !isBase { continue } checked++ if h.Data[i] != float32(man.SeaLevelM) { t.Fatalf("region %d: ocean cell %d came out at %g m, not sea level. The composite writes "+ "only land for exactly this reason, and it is now unsafe", rg.ID, i, h.Data[i]) } } } if checked == 0 { t.Fatal("no ocean cells were checked") } }