Tooling
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
// Package region cuts a planet into the pieces the geology solve runs on.
|
||||
//
|
||||
// Docs/Terrain-Next.md 3.3 says the fluvial solve cannot be tiled, and that is right: drainage area is an
|
||||
// integral over the whole upstream catchment and the priority-flood needs global connectivity, so a river
|
||||
// crossing a tile boundary would need the next tile's catchment to know how big it is.
|
||||
//
|
||||
// It can be decomposed per landmass, though, and that is a different statement. Ocean cells are held fixed
|
||||
// at sea level for the entire solve - fluvial.ComputeReceivers makes every outlet its own receiver, so a
|
||||
// receiver chain starting on land terminates the moment it steps into water, and StreamPower, both
|
||||
// diffusions, the repose clamp and thermal all skip a fixed cell. No flow path crosses open water, and every
|
||||
// basin is contained in one eight-connected land component. So solving a landmass in a box of its own is not
|
||||
// an approximation of solving the planet whole: on land it is the same answer.
|
||||
//
|
||||
// What that buys is memory. The whole planet at once is a fluvial.Grid of about 35 bytes a cell plus the
|
||||
// dozen full-size fields uplift builds, which at 78 million cells is several gigabytes before anything has
|
||||
// been eroded. Landmasses plus a thin margin are a fraction of that area and are solved one at a time.
|
||||
//
|
||||
// What it costs is that the decomposition becomes part of the world's identity: the priority-flood's epsilon
|
||||
// ladder across a flat depends on the flood's traversal order, which depends on the box it is flooding. The
|
||||
// seed alone no longer names a world - the seed and the margin do - so the margin lives in the manifest and
|
||||
// is recorded in meta.json.
|
||||
//
|
||||
// Note what is NOT decomposed. The coastal pass runs once on the whole cylinder, because it is cheap (tens
|
||||
// of nanoseconds a cell, against tens of nanoseconds a cell *per step* for the solve) and because cutting it
|
||||
// up would truncate the fetch across every strait, split the sediment budget whose conservation is the one
|
||||
// thing in that pass not derived from something already measured, and leave the shoreline length and the
|
||||
// exposure percentiles as statistics that do not pool. Decompose the solve, not the map.
|
||||
package region
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Region is one piece of the planet: a landmass, or a cluster of landmasses close enough that they shelter
|
||||
// each other, plus a margin of ocean on every side.
|
||||
type Region struct {
|
||||
ID int
|
||||
Frame world.Frame
|
||||
|
||||
LandCells int // painted land cells this region owns
|
||||
SetCells int // cells in the dilated set, which the frame is the bounding box of
|
||||
Seam bool // the frame straddles x = 0
|
||||
}
|
||||
|
||||
// Cells is the size of the grid the solve will run on, margin included.
|
||||
func (r Region) Cells() int { return r.Frame.Cells() }
|
||||
|
||||
// Partition is a planet cut into regions, and the map from planet cell to owning region.
|
||||
type Partition struct {
|
||||
P world.Planet
|
||||
MarginCells int
|
||||
|
||||
// Owner is the region id for every planet cell, or -1 for water that belongs to no region. A cell
|
||||
// inside one region's frame may be owned by another region or by nobody, which is what keeps two
|
||||
// regions from both solving the same island.
|
||||
Owner []int32
|
||||
Regions []Region
|
||||
|
||||
// Dropped counts the specks: components with less painted land than the minimum, returned to the sea.
|
||||
DroppedRegions, DroppedCells int
|
||||
}
|
||||
|
||||
// Build partitions a classified planet.
|
||||
//
|
||||
// The land mask is dilated by the margin with one exact distance transform, and the connected components of
|
||||
// the dilated mask are the regions. That is the whole rule, and it is deliberately not a bounding-box
|
||||
// overlap test: dilated boxes are transitively closed and one long thin landmass has an enormous box, so on
|
||||
// a real template box clustering collapses most of the map into a single region. Dilating the mask itself
|
||||
// groups exactly those landmasses that come within a margin of each other.
|
||||
//
|
||||
// The bounding box of a dilated component is the region's frame, and its edges are ocean by construction: a
|
||||
// land cell dilates to reach margin cells further out, so the outermost column and row of the dilated set
|
||||
// are at least margin cells from any land in that component. That is the invariant TestBorderIsAlwaysOcean
|
||||
// asserts about the square canvas, and the solve depends on it - a border cell is an outlet, and land
|
||||
// sitting on one would freeze at its initial relief while the interior eroded out from under it.
|
||||
func Build(m *template.Map, marginCells, minLandCells int) (*Partition, error) {
|
||||
p := m.P
|
||||
n := p.W * p.H
|
||||
if marginCells < 1 {
|
||||
return nil, fmt.Errorf("margin is %d cells; a region needs at least one ring of ocean", marginCells)
|
||||
}
|
||||
if p.PadY < marginCells {
|
||||
return nil, fmt.Errorf("the polar pad is %d rows against a %d cell margin; a cap touching the top "+
|
||||
"of the painted map would not get a full margin of ocean", p.PadY, marginCells)
|
||||
}
|
||||
|
||||
part := &Partition{P: p, MarginCells: marginCells, Owner: minusOne(n)}
|
||||
|
||||
land := make([]bool, n)
|
||||
anyLand := false
|
||||
for i := range m.Sea {
|
||||
land[i] = !m.Sea[i]
|
||||
anyLand = anyLand || land[i]
|
||||
}
|
||||
if !anyLand {
|
||||
return part, nil
|
||||
}
|
||||
|
||||
near := dilate(land, p, marginCells)
|
||||
|
||||
comp := make([]int32, n)
|
||||
for i := range comp {
|
||||
comp[i] = -1
|
||||
}
|
||||
var regionOfComp []int32 // one entry per component: the region index, or -1 when it was dropped
|
||||
var stack []int32
|
||||
cols := make([]bool, p.W)
|
||||
|
||||
for start := 0; start < n; start++ {
|
||||
if !near[start] || comp[start] >= 0 {
|
||||
continue
|
||||
}
|
||||
id := int32(len(regionOfComp))
|
||||
regionOfComp = append(regionOfComp, -1)
|
||||
comp[start] = id
|
||||
stack = append(stack[:0], int32(start))
|
||||
|
||||
for i := range cols {
|
||||
cols[i] = false
|
||||
}
|
||||
minY, maxY := p.H, -1
|
||||
setCells, landCells := 0, 0
|
||||
|
||||
for len(stack) > 0 {
|
||||
c := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
cx, cy := int(c)%p.W, int(c)/p.W
|
||||
setCells++
|
||||
cols[cx] = true
|
||||
if cy < minY {
|
||||
minY = cy
|
||||
}
|
||||
if cy > maxY {
|
||||
maxY = cy
|
||||
}
|
||||
if land[c] {
|
||||
landCells++
|
||||
}
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := cy + dy
|
||||
if ny < 0 || ny >= p.H {
|
||||
continue
|
||||
}
|
||||
base := ny * p.W
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
ni := int32(base + p.WrapX(cx+dx))
|
||||
if near[ni] && comp[ni] < 0 {
|
||||
comp[ni] = id
|
||||
stack = append(stack, ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if landCells < minLandCells {
|
||||
// A speck: a stray paint pixel, or a lone cell the classifier left behind. Solving it would
|
||||
// spend a whole region on a rock, so it goes back to the sea and is counted.
|
||||
part.DroppedRegions++
|
||||
part.DroppedCells += landCells
|
||||
continue
|
||||
}
|
||||
|
||||
x0, width := span(cols, p.W)
|
||||
if width >= p.W {
|
||||
return nil, fmt.Errorf("a landmass reaches all the way round the planet: %d of %d columns once "+
|
||||
"the %d cell margin is added. It cannot be flattened into a rectangle with ocean on both "+
|
||||
"sides, and the solve needs that, because a grid edge is an outlet. Break it with a strait, "+
|
||||
"or reduce the margin", width, p.W, marginCells)
|
||||
}
|
||||
|
||||
regionOfComp[id] = int32(len(part.Regions))
|
||||
part.Regions = append(part.Regions, Region{
|
||||
ID: len(part.Regions),
|
||||
Frame: world.Frame{P: p, X0: x0, Y0: minY, W: width, H: maxY - minY + 1},
|
||||
LandCells: landCells,
|
||||
SetCells: setCells,
|
||||
Seam: x0+width > p.W,
|
||||
})
|
||||
}
|
||||
|
||||
for i, c := range comp {
|
||||
if c >= 0 {
|
||||
part.Owner[i] = regionOfComp[c]
|
||||
}
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
|
||||
// dilate marks every cell within margin cells of a seed, on the cylinder.
|
||||
func dilate(seed []bool, p world.Planet, margin int) []bool {
|
||||
d2 := dt.Distance2(seed, p.W, p.H, true)
|
||||
reach := float32(margin * margin)
|
||||
out := make([]bool, len(d2))
|
||||
for i, d := range d2 {
|
||||
out[i] = d <= reach
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Cut is the region's own view of the world: the class raster and the land mask for its frame, with every
|
||||
// cell belonging to another region - or to no region - forced to sea.
|
||||
//
|
||||
// Forcing them is right rather than convenient. A neighbouring island inside this frame is a separate
|
||||
// landmass with its own basins, and no flow path connects the two, so leaving it as land would solve it
|
||||
// twice and let its relief leak into this region's statistics. As water it is exactly what it is to this
|
||||
// region's rivers: base level.
|
||||
func (p *Partition) Cut(m *template.Map, r Region) (class []uint8, land []bool) {
|
||||
sea := uint8(0)
|
||||
if i := m.L.FirstSea(); i >= 0 {
|
||||
sea = uint8(i)
|
||||
}
|
||||
class = make([]uint8, r.Frame.Cells())
|
||||
land = make([]bool, r.Frame.Cells())
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
o := y*r.Frame.W + x
|
||||
mine := p.Owner[pi] == int32(r.ID)
|
||||
if mine && !m.Sea[pi] {
|
||||
class[o] = m.Class[pi]
|
||||
land[o] = true
|
||||
continue
|
||||
}
|
||||
if m.Sea[pi] {
|
||||
class[o] = m.Class[pi] // keep the painted water class: its depth is read later
|
||||
} else {
|
||||
class[o] = sea // somebody else's land, which to this region is open water
|
||||
}
|
||||
}
|
||||
}
|
||||
return class, land
|
||||
}
|
||||
|
||||
// Composite writes a region's solved land back into the planet raster.
|
||||
//
|
||||
// Only cells the region owns and that are painted land are written. Everything else in the frame is water,
|
||||
// and the sea floor is the planetary coastal pass's to lay afterwards - a region must not write it, or two
|
||||
// overlapping frames would disagree about the same stretch of shelf.
|
||||
func (p *Partition) Composite(dst []float32, m *template.Map, r Region, src []float32) int {
|
||||
written := 0
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
if p.Owner[pi] != int32(r.ID) || m.Sea[pi] {
|
||||
continue
|
||||
}
|
||||
dst[pi] = src[y*r.Frame.W+x]
|
||||
written++
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// span finds the shortest run of columns covering every occupied one, going round the cylinder. The largest
|
||||
// gap decides: the run starts just after it.
|
||||
func span(cols []bool, w int) (x0, width int) {
|
||||
occupied := make([]int, 0, w)
|
||||
for x, on := range cols {
|
||||
if on {
|
||||
occupied = append(occupied, x)
|
||||
}
|
||||
}
|
||||
if len(occupied) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if len(occupied) == w {
|
||||
return 0, w
|
||||
}
|
||||
bestGap, bestAt := -1, 0
|
||||
for i := range occupied {
|
||||
var gap int
|
||||
if i == len(occupied)-1 {
|
||||
gap = occupied[0] + w - occupied[i]
|
||||
} else {
|
||||
gap = occupied[i+1] - occupied[i]
|
||||
}
|
||||
if gap > bestGap {
|
||||
bestGap, bestAt = gap, (i+1)%len(occupied)
|
||||
}
|
||||
}
|
||||
return occupied[bestAt], w - bestGap + 1
|
||||
}
|
||||
|
||||
func minusOne(n int) []int32 {
|
||||
out := make([]int32, n)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const legendJSON = `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":100},
|
||||
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.5}
|
||||
]}`
|
||||
|
||||
// testMap builds a planet from a picture of its painted rows. '#' is land, '.' is sea; the polar pad of
|
||||
// synthetic ocean is added above and below.
|
||||
func testMap(t *testing.T, rows []string, pad int) *template.Map {
|
||||
t.Helper()
|
||||
l, err := template.Parse([]byte(legendJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := len(rows[0])
|
||||
p := world.Planet{CellM: 1, W: w, H: len(rows) + 2*pad, PadY: pad, NoisePeriodM: float64(w)}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &template.Map{P: p, L: l, 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] = 0, true
|
||||
}
|
||||
for y, row := range rows {
|
||||
if len(row) != w {
|
||||
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
|
||||
}
|
||||
for x, r := range row {
|
||||
if r == '#' {
|
||||
i := (y+pad)*p.W + x
|
||||
m.Class[i], m.Sea[i] = 1, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// assertBordersAreWater is the invariant the whole solve depends on, and the direct analogue of
|
||||
// TestBorderIsAlwaysOcean: a grid edge is an outlet, so land on one would freeze at its initial relief while
|
||||
// the interior eroded out from under it.
|
||||
func assertBordersAreWater(t *testing.T, part *Partition, m *template.Map) {
|
||||
t.Helper()
|
||||
for _, r := range part.Regions {
|
||||
_, land := part.Cut(m, r)
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
if land[x] {
|
||||
t.Errorf("region %d: land on the top edge at column %d", r.ID, x)
|
||||
}
|
||||
if land[(r.Frame.H-1)*r.Frame.W+x] {
|
||||
t.Errorf("region %d: land on the bottom edge at column %d", r.ID, x)
|
||||
}
|
||||
}
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
if land[y*r.Frame.W] {
|
||||
t.Errorf("region %d: land on the left edge at row %d", r.ID, y)
|
||||
}
|
||||
if land[y*r.Frame.W+r.Frame.W-1] {
|
||||
t.Errorf("region %d: land on the right edge at row %d", r.ID, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The crater island in the template this was written for straddles x = 0. If the partitioner split it in
|
||||
// two, half of it would be solved against a shore that does not exist.
|
||||
func TestSeamStraddlingLandmassIsOneRegion(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"#.........#",
|
||||
"#.........#",
|
||||
"...........",
|
||||
}, 2)
|
||||
part, err := Build(m, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1: the landmass wraps", len(part.Regions))
|
||||
}
|
||||
r := part.Regions[0]
|
||||
if !r.Seam {
|
||||
t.Error("the region does not report that it straddles the seam")
|
||||
}
|
||||
if r.LandCells != 4 {
|
||||
t.Errorf("LandCells = %d, want 4", r.LandCells)
|
||||
}
|
||||
// Land occupies columns 10 and 0, which are neighbours on an 11-column cylinder; a 2-cell margin on
|
||||
// each side makes the frame six columns wide starting at column 8.
|
||||
if r.Frame.W != 6 {
|
||||
t.Errorf("frame width = %d, want 6", r.Frame.W)
|
||||
}
|
||||
if r.Frame.X0 != 8 {
|
||||
t.Errorf("frame X0 = %d, want 8", r.Frame.X0)
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// Landmasses close enough to shelter each other are solved together; further apart they are not. The
|
||||
// alternative that was rejected - overlapping dilated bounding boxes - is transitively closed and one long
|
||||
// landmass has an enormous box, so on a real template it collapses most of the map into a single region.
|
||||
func TestClusteringFollowsDistanceNotBoundingBoxes(t *testing.T) {
|
||||
near := testMap(t, []string{
|
||||
"..............................",
|
||||
".####....#....................",
|
||||
".####....#....................",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err := Build(near, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1: a four-cell gap closes under a three-cell margin on each side",
|
||||
len(part.Regions))
|
||||
}
|
||||
|
||||
far := testMap(t, []string{
|
||||
"..............................",
|
||||
".####.........#...............",
|
||||
".####.........#...............",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err = Build(far, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 2 {
|
||||
t.Fatalf("got %d regions, want 2: a nine-cell gap does not", len(part.Regions))
|
||||
}
|
||||
assertBordersAreWater(t, part, far)
|
||||
}
|
||||
|
||||
func TestEveryRegionBorderIsWater(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"..###...........................................................",
|
||||
"..###.........####.............####.............##..............",
|
||||
"..............####.............####.............##..............",
|
||||
"..............####.............####.............................",
|
||||
"................................................................",
|
||||
"................................................................",
|
||||
}, 3)
|
||||
part, err := Build(m, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 2 {
|
||||
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// A polar cap touches the top row of the painted map. The synthetic ocean pad is what gives it a shore, so
|
||||
// that fluvial.isOutlet - which treats every top-row cell as an outlet - is answering about water.
|
||||
func TestAPolarCapGetsAMarginOfOcean(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"###############...............",
|
||||
"########......................",
|
||||
"..............................",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err := Build(m, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
||||
}
|
||||
r := part.Regions[0]
|
||||
if r.Frame.Y0 != 0 {
|
||||
t.Errorf("frame Y0 = %d, want 0: the cap reaches into the pad", r.Frame.Y0)
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// Every painted land cell belongs to exactly one region, and a round trip through Cut and Composite
|
||||
// reproduces it. If two regions owned the same cell, one would silently overwrite the other.
|
||||
func TestCutAndCompositeCoverEveryLandCellOnce(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"#..####...................#",
|
||||
"#..####...................#",
|
||||
"...........................",
|
||||
"..........##.....##........",
|
||||
"..........##.....##........",
|
||||
"...........................",
|
||||
}, 3)
|
||||
part, err := Build(m, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 2 {
|
||||
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
||||
}
|
||||
|
||||
dst := make([]float32, m.P.W*m.P.H)
|
||||
hits := make([]int, len(dst))
|
||||
total := 0
|
||||
for _, r := range part.Regions {
|
||||
_, land := part.Cut(m, r)
|
||||
src := make([]float32, r.Frame.Cells())
|
||||
for i := range src {
|
||||
if land[i] {
|
||||
src[i] = float32(r.ID + 1)
|
||||
}
|
||||
}
|
||||
total += part.Composite(dst, m, r, src)
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
if part.Owner[pi] == int32(r.ID) && !m.Sea[pi] {
|
||||
hits[pi]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
painted := 0
|
||||
for i := range m.Sea {
|
||||
if !m.Sea[i] {
|
||||
painted++
|
||||
}
|
||||
}
|
||||
if total != painted {
|
||||
t.Errorf("composited %d land cells, but %d are painted", total, painted)
|
||||
}
|
||||
for i, n := range hits {
|
||||
if m.Sea[i] {
|
||||
if n != 0 {
|
||||
t.Fatalf("water cell %d was written %d times", i, n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("land cell %d was written %d times, want exactly 1", i, n)
|
||||
}
|
||||
if dst[i] == 0 {
|
||||
t.Fatalf("land cell %d came back zero", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A landmass that rings the planet cannot be flattened into a rectangle with water on both sides, and the
|
||||
// solve needs that. Better a clear refusal than a silently frozen coastline.
|
||||
func TestALandmassRingingThePlanetIsRefused(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"##########",
|
||||
"..........",
|
||||
"..........",
|
||||
"..........",
|
||||
}, 2)
|
||||
_, err := Build(m, 2, 1)
|
||||
if err == nil {
|
||||
t.Fatal("accepted a landmass that goes all the way round")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "all the way round") {
|
||||
t.Errorf("error %q does not say why", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stray paint pixel should not cost a whole region.
|
||||
func TestSpecksAreDroppedAndCounted(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"####......................",
|
||||
"####...............#......",
|
||||
"####......................",
|
||||
"..........................",
|
||||
}, 2)
|
||||
part, err := Build(m, 2, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
||||
}
|
||||
if part.DroppedRegions != 1 || part.DroppedCells != 1 {
|
||||
t.Errorf("dropped %d regions / %d cells, want 1 and 1", part.DroppedRegions, part.DroppedCells)
|
||||
}
|
||||
// And the speck is not owned by anything, so nothing solves it.
|
||||
for i := range m.Sea {
|
||||
if !m.Sea[i] && part.Owner[i] < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the speck is still owned by a region")
|
||||
}
|
||||
|
||||
func TestTheMarginMustFitInsideThePad(t *testing.T) {
|
||||
m := testMap(t, []string{"####......", ".........."}, 1)
|
||||
if _, err := Build(m, 4, 1); err == nil {
|
||||
t.Fatal("accepted a margin wider than the polar pad")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpanWrapsTheShortWay(t *testing.T) {
|
||||
mark := func(w int, on ...int) []bool {
|
||||
c := make([]bool, w)
|
||||
for _, x := range on {
|
||||
c[x] = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
cases := []struct {
|
||||
cols []bool
|
||||
w int
|
||||
x0, wanted int
|
||||
}{
|
||||
{mark(10, 0, 1, 2), 10, 0, 3},
|
||||
{mark(10, 8, 9, 0, 1), 10, 8, 4},
|
||||
{mark(10, 5), 10, 5, 1},
|
||||
{mark(10, 0, 5), 10, 5, 6},
|
||||
}
|
||||
for _, c := range cases {
|
||||
x0, w := span(c.cols, c.w)
|
||||
if x0 != c.x0 || w != c.wanted {
|
||||
t.Errorf("span = %d+%d, want %d+%d", x0, w, c.x0, c.wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user