package plates import ( "math" "sort" "testing" "salty/terrain/internal/world" ) // testPlanet is a small cylinder with the same shape of arithmetic as a real one: a whole number of columns // and a noise period that divides the circumference. func testPlanet(t *testing.T) world.Planet { t.Helper() p, err := world.New(40000, 8, 100, 50, 0, 40000) if err != nil { t.Fatalf("planet: %v", err) } return p } // handModel builds a Model with a raster set by the caller, so a test can ask about a boundary in a known // place. Nothing here goes through Build: the point is to control the partition rather than the seed. func handModel(t *testing.T, p world.Planet, gw, gh int, cell []int16, ps []Plate) *Model { t.Helper() return &Model{ P: p, Cfg: Default(), Plates: ps, GW: gw, GH: gh, GCellM: p.CircumferenceM() / float64(gw), Cell: cell, } } // stripes paints two vertical bands: plate 1 from column lo up to hi, plate 0 everywhere else. // // That is **two** contacts, not one, and it is worth saying why every test here is written in pairs. A // cylinder cut into two strips has a margin at each end of each strip, and under a pure translation the // plates are closing at one of them and opening at the other by exactly the same amount. There is no way to // arrange two plates on a cylinder that only collide. The invariant is the test. func stripes(gw, gh, lo, hi int) []int16 { cell := make([]int16, gw*gh) for gy := range gh { for gx := range gw { if gx >= lo && gx < hi { cell[gy*gw+gx] = 1 } } } return cell } // rollX moves every column east by n, wrapping. Rolling the map is the whole seam test: the cylinder has no // preferred meridian, so a partition and the same partition rolled must produce the same tectonics. func rollX(cell []int16, gw, gh, n int) []int16 { out := make([]int16, len(cell)) for gy := range gh { for gx := range gw { out[gy*gw+((gx+n)%gw)] = cell[gy*gw+gx] } } return out } // closingPair drives plate 0 east and plate 1 west, with no spin. func closingPair() []Plate { return []Plate{ {ID: 0, TransXM: 0.02, Continental: true, AreaCells: 1}, {ID: 1, TransXM: -0.02, Continental: true, AreaCells: 1}, } } // meanClosing is a boundary's average closing rate, in metres a year. func meanClosing(b Boundary) float64 { if len(b.V) == 0 { return 0 } total := 0.0 for _, v := range b.V { total += v.ClosingMYr } return total / float64(len(b.V)) } // sortedMeans is every boundary's mean closing rate, in order: the signature of a whole planet's tectonics, // independent of which order the boundaries happened to be found in. func sortedMeans(bs []Boundary) []float64 { out := make([]float64, len(bs)) for i, b := range bs { out[i] = meanClosing(b) } sort.Float64s(out) return out } func kinds(bs []Boundary) []string { out := make([]string, len(bs)) for i, b := range bs { out[i] = b.Dominant().String() } sort.Strings(out) return out } func TestABoundaryAcrossTheSeamIsOneBoundary(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()) bs := m.buildBoundaries() if len(bs) != 2 { t.Fatalf("two vertical contacts on a cylinder, got %d boundaries", len(bs)) } // Roll the partition so one contact sits exactly on the meridian. If the seam were special the boundary // through it would come back cut in half - two chains of half the length - or with a whole circumference // of jump in the middle of it. rolled := handModel(t, p, gw, gh, rollX(m.Cell, gw, gh, 50), closingPair()) rbs := rolled.buildBoundaries() if len(rbs) != 2 { t.Fatalf("after rolling the map onto the seam: %d boundaries, want 2", len(rbs)) } for _, b := range rbs { for i := 0; i+1 < len(b.V); i++ { d := math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM) if d > maxGapCells*m.GCellM*1.01 { t.Fatalf("a %.0f m step between neighbouring vertices: X was wrapped, not unwrapped", d) } } if got, want := b.LengthM(), bs[0].LengthM(); math.Abs(got-want) > m.GCellM { t.Errorf("rolled boundary is %.0f m, unrolled %.0f m", got, want) } } // The tectonics have to be the same planet, not just the same shape: the rolled map's margins close at // the same rates as the unrolled map's. before, after := sortedMeans(bs), sortedMeans(rbs) for i := range before { if math.Abs(before[i]-after[i]) > 1e-9 { t.Errorf("closing rate %d is %.6g before the roll and %.6g after", i, before[i], after[i]) } } circ := p.CircumferenceM() crossed := false for _, b := range rbs { for _, v := range b.V { if v.XM < 0 || v.XM >= circ { crossed = true } } } if !crossed { t.Error("no vertex outside 0..circumference, so nothing was unwrapped and the roll tested nothing") } } func TestClosingIsTheSameWhicheverPlateIsCalledA(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 forward := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()) // Swap which stripe belongs to which plate, and swap the motions with it. Physically nothing has moved: // the same two materials are being driven together at the same margin. Every closing rate must therefore // come back identical, because the normal flips and the relative velocity flips with it. swapped := stripes(gw, gh, 50, 150) for i := range swapped { swapped[i] = 1 - swapped[i] } reverse := handModel(t, p, gw, gh, swapped, []Plate{ {ID: 0, TransXM: -0.02, Continental: true, AreaCells: 1}, {ID: 1, TransXM: 0.02, Continental: true, AreaCells: 1}, }) fm, rm := sortedMeans(forward.buildBoundaries()), sortedMeans(reverse.buildBoundaries()) if len(fm) != len(rm) { t.Fatalf("%d boundaries one way round and %d the other", len(fm), len(rm)) } for i := range fm { if math.Abs(fm[i]-rm[i]) > 1e-9 { t.Errorf("closing rate %d is %.6g one way round and %.6g the other; the sign convention is not "+ "symmetric", i, fm[i], rm[i]) } } } func TestOneMarginClosesAndTheOtherOpens(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 bs := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()).buildBoundaries() if len(bs) != 2 { t.Fatalf("got %d boundaries, want 2", len(bs)) } means := sortedMeans(bs) if means[0] >= 0 || means[1] <= 0 { t.Fatalf("closing rates %.4g and %.4g; two strips on a cylinder give one of each", means[0], means[1]) } // Equal and opposite, because a pure translation is the same relative velocity at both margins and the // only thing that differs is which way the normal points. if math.Abs(means[0]+means[1]) > 1e-9 { t.Errorf("closing rates %.6g and %.6g are not equal and opposite", means[0], means[1]) } if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-9 { t.Errorf("plates at 2 cm/yr each close at %.4g m/yr; 0.04 is the sum of the two speeds", got) } } func TestContinentsCollideAndOceansSubduct(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 cell := stripes(gw, gh, 50, 150) both := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries() if got, want := kinds(both), []string{"collision", "rift"}; !sameStrings(got, want) { t.Errorf("two continental plates give %v, want %v", got, want) } // The same geometry with one side oceanic: the closing margin is a subduction zone and the arc belongs // to the continent, because continental crust is too buoyant to go down. ps := closingPair() ps[1].Continental = false oceanic := handModel(t, p, gw, gh, cell, ps).buildBoundaries() if got, want := kinds(oceanic), []string{"ridge", "subduction"}; !sameStrings(got, want) { t.Errorf("continent against ocean gives %v, want %v", got, want) } for _, b := range oceanic { for _, v := range b.V { if v.Kind == Subduction && v.Over != 0 { t.Fatalf("the overriding plate is %d, but plate 1 is the oceanic one", v.Over) } if v.Kind != Subduction && v.Over != -1 { t.Fatalf("a %q vertex carries an overriding plate", v.Kind) } } } } func sameStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func TestMotionAlongTheLineIsATransform(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 // Vertical contacts, both plates sliding north and south: the relative motion is entirely along the line, // so neither margin closes or opens. m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), []Plate{ {ID: 0, TransYM: 0.02, Continental: true, AreaCells: 1}, {ID: 1, TransYM: -0.02, Continental: true, AreaCells: 1}, }) for _, b := range m.buildBoundaries() { if got := b.Dominant(); got != Transform { t.Errorf("plates sliding past each other give %q, want %q", got, Transform) } if got := math.Abs(meanClosing(b)); got > 1e-9 { t.Errorf("a transform margin closes at %.3g m/yr", got) } } } func TestKindOf(t *testing.T) { oblique := 60 * math.Pi / 180 cases := []struct { name string closing, slip float64 bothContinental bool want Kind }{ {"head-on continental", 1, 0, true, Collision}, {"head-on with an ocean", 1, 0, false, Subduction}, {"opening continental", -1, 0, true, Rift}, {"opening with an ocean", -1, 0, false, Ridge}, {"pure slip", 0, 1, true, Transform}, {"oblique but still closing", 1, 1.5, true, Collision}, {"slip has taken over", 1, 2, true, Transform}, } for _, c := range cases { if got := kindOf(c.closing, c.slip, oblique, c.bothContinental); got != c.want { t.Errorf("%s: got %q, want %q", c.name, got, c.want) } } } func TestSpinMakesAMarginChangeAlongItsLength(t *testing.T) { p := testPlanet(t) const gw, gh = 200, 100 cell := stripes(gw, gh, 50, 150) // Pure translation: the relative velocity is the same everywhere, so a straight margin closes at one rate // from end to end. That uniformity is exactly what the in-plane pole exists to break. flat := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries() if spread := worstSpread(flat); spread > 1e-9 { t.Errorf("without spin a margin varies by %.3g m/yr along its own length; it should not", spread) } // The same plates with one rotating about a pole off to the side close at one end and slide at the other. spun := closingPair() spun[0].PoleXM, spun[0].PoleYM = 0, 0 spun[0].OmegaRadYr = 2e-6 spinning := handModel(t, p, gw, gh, cell, spun).buildBoundaries() if spread := worstSpread(spinning); spread < 1e-3 { t.Errorf("with spin a margin varies by only %.3g m/yr; the rotation is not reaching the boundary", spread) } } // worstSpread is the largest range of closing rates found *within* a single boundary. Within, not across: // two margins of the same pair legitimately differ, and measuring across them would report that difference // as variation along a line. func worstSpread(bs []Boundary) float64 { worst := 0.0 for _, b := range bs { lo, hi := math.Inf(1), math.Inf(-1) for _, v := range b.V { lo = math.Min(lo, v.ClosingMYr) hi = math.Max(hi, v.ClosingMYr) } if !math.IsInf(lo, 1) && hi-lo > worst { worst = hi - lo } } return worst } func TestVelocityIsContinuousAcrossTheSeam(t *testing.T) { p := testPlanet(t) circ := p.CircumferenceM() // A plate whose pole sits just east of the meridian. Measured without wrapping, the lever arm a metre // west of the seam would be a whole circumference long, and the plate would spin the wrong way there. pl := Plate{ID: 0, PoleXM: 10, PoleYM: 0, OmegaRadYr: 1e-6, TransXM: 0.01} ax, ay := pl.VelocityAt(p, circ-1, 0) bx, by := pl.VelocityAt(p, 1, 0) acrossSeam := math.Hypot(ax-bx, ay-by) // The same two-metre gap in open map, away from the meridian: the seam must cost nothing extra. cx, cy := pl.VelocityAt(p, circ/2-1, 0) dx, dy := pl.VelocityAt(p, circ/2+1, 0) elsewhere := math.Hypot(cx-dx, cy-dy) if math.Abs(acrossSeam-elsewhere) > 1e-12 { t.Errorf("velocity changes by %.3g m/yr over two metres at the meridian and %.3g m/yr over two "+ "metres anywhere else", acrossSeam, elsewhere) } // And the failure this guards against is enormous, not subtle: an unwrapped lever arm would be a whole // circumference and give a jump of omega*circ. if acrossSeam > pl.OmegaRadYr*circ/100 { t.Errorf("velocity jumps by %.3g m/yr at the meridian; the lever arm was not wrapped", acrossSeam) } } func TestBuildCoversThePlanetAndReadsTheLandMask(t *testing.T) { p := testPlanet(t) cfg := Default() cfg.Count = 6 allSea, err := Build(p, 7, cfg, func(xM, yM float64) bool { return false }) if err != nil { t.Fatalf("build: %v", err) } total := 0 for _, pl := range allSea.Plates { total += pl.AreaCells if pl.Continental { t.Errorf("plate %d is continental on a planet with no land", pl.ID) } } if total != allSea.GW*allSea.GH { t.Errorf("plates cover %d cells of %d; the partition has holes", total, allSea.GW*allSea.GH) } if len(allSea.Boundaries) == 0 { t.Fatal("six plates and no boundaries between them") } for _, b := range allSea.Boundaries { if b.A >= b.B { t.Errorf("boundary pair (%d, %d) is not ordered", b.A, b.B) } for _, v := range b.V { if v.Kind == Collision || v.Kind == Rift { t.Errorf("a %q on a planet with no continental plate at all", v.Kind) } } } allLand, err := Build(p, 7, cfg, func(xM, yM float64) bool { return true }) if err != nil { t.Fatalf("build: %v", err) } for _, pl := range allLand.Plates { if !pl.Continental { t.Errorf("plate %d is oceanic on a planet that is all land", pl.ID) } } for _, b := range allLand.Boundaries { for _, v := range b.V { if v.Kind == Subduction || v.Kind == Ridge { t.Errorf("a %q with no oceanic plate to make it", v.Kind) } } } } func TestTheSameSeedGivesTheSamePlanet(t *testing.T) { p := testPlanet(t) cfg := Default() land := func(xM, yM float64) bool { return yM > 5000 && yM < 12000 } a, err := Build(p, 9342, cfg, land) if err != nil { t.Fatalf("build: %v", err) } b, err := Build(p, 9342, cfg, land) if err != nil { t.Fatalf("build: %v", err) } if len(a.Boundaries) != len(b.Boundaries) { t.Fatalf("%d boundaries then %d; the set is not deterministic", len(a.Boundaries), len(b.Boundaries)) } for i := range a.Boundaries { if a.Boundaries[i].A != b.Boundaries[i].A || a.Boundaries[i].B != b.Boundaries[i].B { t.Fatalf("boundary %d is a different pair on the second run", i) } if math.Abs(a.Boundaries[i].LengthM()-b.Boundaries[i].LengthM()) > 1e-9 { t.Fatalf("boundary %d is a different length on the second run", i) } } } func TestTheWarpBendsMarginsAndTooMuchOfItBreaksThem(t *testing.T) { p := testPlanet(t) // Sinuosity - the line's own length over the distance between its ends - is what "a margin is not a // ruled line" means as a number. A weighted Voronoi edge is a circular arc even at no warp, so the // baseline is a little over 1 rather than exactly 1. measure := func(warp float64) (sinuosity float64, boundaries int) { cfg := Default() cfg.Count = 7 cfg.WarpFraction = warp m, err := Build(p, 3630, cfg, func(xM, yM float64) bool { return false }) if err != nil { t.Fatalf("warp %.2f: %v", warp, err) } total, n := 0.0, 0 for _, b := range m.Boundaries { if len(b.V) < 10 { continue } last := b.V[len(b.V)-1] if straight := math.Hypot(last.XM-b.V[0].XM, last.YM-b.V[0].YM); straight > 0 { total += b.LengthM() / straight n++ } } if n == 0 { return 0, len(m.Boundaries) } return total / float64(n), len(m.Boundaries) } straight, straightCount := measure(0) warped, warpedCount := measure(Default().WarpFraction) if straight > 1.05 { t.Errorf("an unwarped partition already has a sinuosity of %.3f; it should be close to a polygon net", straight) } if warped <= straight*1.03 { t.Errorf("the warp takes sinuosity from %.3f to %.3f, which is no bend at all", straight, warped) } // Past about half a plate spacing the displacement folds back on itself and the partition grows islands // of one plate inside another, which the tracer faithfully chains into extra rings. The count is the // symptom, and this is the bound warpFineGain and the default are set under. _, tooMuch := measure(0.5) if tooMuch <= warpedCount { t.Skipf("no fragmentation at warp 0.5 on this seed (%d boundaries against %d); the bound still holds "+ "but this seed does not show it", tooMuch, warpedCount) } if warpedCount != straightCount { t.Errorf("the default warp changed the boundary count from %d to %d; it should bend margins, not "+ "create them", straightCount, warpedCount) } }