package planet import ( "math" "testing" "salty/terrain/internal/region" "salty/terrain/internal/template" "salty/terrain/internal/world" ) // A region label is a number written on a picture, so the only thing worth asserting about it is that it lands // on the region it names. // // The seam is the case that fails silently. A landmass with land at x=0 and at x=W-1 has an arithmetic mean // halfway round the planet - on some other continent entirely - and the map would look perfectly plausible // while being wrong about the one thing the number is for. Everything else here is a guard on the arithmetic // around it: the polar pad is not painted rows, and a region too small to catch a sample still has to get a // position rather than a NaN. const ( labelW = 64 labelPadY = 4 labelPaint = 32 ) // labelFixture is a planet with three regions painted onto it: a compact island in the middle, an island over // the seam, and a single cell small enough that the stride walks straight past it. func labelFixture() *Inputs { p := world.Planet{ CellM: 1, W: labelW, H: labelPaint + 2*labelPadY, PadY: labelPadY, NoisePeriodM: float64(labelW), } n := p.W * p.H m := &template.Map{P: p, Class: make([]uint8, n), Sea: make([]bool, n)} for i := range m.Sea { m.Sea[i] = true } part := ®ion.Partition{P: p, MarginCells: 1, Owner: make([]int32, n)} for i := range part.Owner { part.Owner[i] = -1 } land := func(r int32, x0, x1, py0, py1 int) { for py := py0; py <= py1; py++ { for x := x0; x <= x1; x++ { i := (labelPadY+py)*p.W + ((x%p.W)+p.W)%p.W part.Owner[i], m.Sea[i] = r, false } } } // 0: a compact island around the middle of the map, centred on x = 31.5, painted row 15.5. land(0, 28, 35, 12, 19) part.Regions = append(part.Regions, region.Region{ ID: 0, Frame: world.Frame{P: p, X0: 28, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, }) // 1: the same island moved onto the seam, centred on x = 63.5 - half of it at x >= 60 and half at x <= 3. land(1, 60, 67, 12, 19) part.Regions = append(part.Regions, region.Region{ ID: 1, Frame: world.Frame{P: p, X0: 60, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, Seam: true, }) // 2: one cell, at coordinates the stride never samples, so this is the fallback path. land(2, 1, 1, 1, 1) part.Regions = append(part.Regions, region.Region{ ID: 2, Frame: world.Frame{P: p, X0: 1, Y0: labelPadY + 1, W: 1, H: 1}, LandCells: 1, }) return &Inputs{P: p, Map: m, Part: part} } // circDist is the distance between two positions round the cylinder, in fractions of a turn. func circDist(a, b float64) float64 { d := math.Abs(a - b) return math.Min(d, 1-d) } func TestRegionLabelsLandOnTheirRegion(t *testing.T) { in := labelFixture() got := in.RegionLabels() if len(got) != 3 { t.Fatalf("got %d labels for 3 regions", len(got)) } // The compact island. The stride costs up to two cells of accuracy in each axis, which is 0.03 of a turn // across and 0.06 down, and a label is allowed to be that far from dead centre. const wantU0, wantV0 = 31.5 / labelW, 15.5 / labelPaint if d := circDist(got[0][0], wantU0); d > 0.05 { t.Errorf("region 0 u = %.4f, want within 0.05 of %.4f (off by %.4f)", got[0][0], wantU0, d) } if d := math.Abs(got[0][1] - wantV0); d > 0.08 { t.Errorf("region 0 v = %.4f, want within 0.08 of %.4f", got[0][1], wantV0) } // The seam island, and the reason this file exists. Its land is centred on x = 63.5, a quarter of a cell // short of the seam. An arithmetic mean of those columns gives 31.5, which is region 0's island: if this // assertion ever fails by landing near 0.49, the circular mean has been lost. const wantU1 = 63.5 / labelW if d := circDist(got[1][0], wantU1); d > 0.06 { t.Errorf("region 1 u = %.4f, want within 0.06 of %.4f (off by %.4f); "+ "0.49 means the mean across is no longer circular", got[1][0], wantU1, d) } if d := math.Abs(got[1][1] - wantV0); d > 0.08 { t.Errorf("region 1 v = %.4f, want within 0.08 of %.4f", got[1][1], wantV0) } // The single cell, which no sample touches: the frame centre, and in particular a v measured from the // painted rows rather than from the top of the polar pad. const wantU2, wantV2 = 1.5 / labelW, 1.5 / labelPaint if d := circDist(got[2][0], wantU2); d > 0.01 { t.Errorf("region 2 u = %.4f, want %.4f from its frame", got[2][0], wantU2) } if d := math.Abs(got[2][1] - wantV2); d > 0.01 { t.Errorf("region 2 v = %.4f, want %.4f from its frame; a v of %.4f would be measuring from the "+ "top of the pad instead of the first painted row", got[2][1], wantV2, float64(labelPadY+1)/labelPaint) } for i, l := range got { if math.IsNaN(l[0]) || math.IsNaN(l[1]) || l[0] < 0 || l[0] > 1 || l[1] < 0 || l[1] > 1 { t.Errorf("region %d label %v is outside the drawn map", i, l) } } } // The hues have to be far enough apart to tell one landmass from another, which is the whole reason they // stopped coming out of a hash of the index. See RegionHues for the argument; this is the measurement. func TestRegionHuesStayApart(t *testing.T) { const regions = 40 in := &Inputs{Part: ®ion.Partition{Regions: make([]region.Region, regions)}} hues := in.RegionHues() worst, wa, wb := math.MaxFloat64, 0, 0 for a := 0; a < regions; a++ { for b := a + 1; b < regions; b++ { dr := float64(hues[a][0]) - float64(hues[b][0]) dg := float64(hues[a][1]) - float64(hues[b][1]) db := float64(hues[a][2]) - float64(hues[b][2]) if d := math.Sqrt(dr*dr + dg*dg + db*db); d < worst { worst, wa, wb = d, a, b } } } t.Logf("closest of %d hues: %.1f, between region %d and region %d", regions, worst, wa, wb) // Measured: the walk gives 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty, against 8.5 for // the hash this replaced. The floor is set well below what the walk achieves rather than at it, so that a // change to the saturation or value cycle is free to move the number a little and not free to collapse it. const floor = 25 if worst < floor { t.Errorf("closest two hues are %.1f apart (regions %d and %d), want at least %d: "+ "two landmasses that colour alike is the defect this walk exists to prevent", worst, wa, wb, floor) } }