Tooling
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A small world with a sea on the left, a flat coastal plain, and a steep ridge on the right, so every
|
||||
// generated kind has somewhere it should go and somewhere it should not.
|
||||
func testWorld(w, h int) GenInputs {
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
flow := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
switch {
|
||||
case x < w/5:
|
||||
sea[i] = true
|
||||
height[i] = -50
|
||||
case x < 3*w/5:
|
||||
height[i] = float32(x-w/5) * 0.2 // a gentle plain
|
||||
default:
|
||||
height[i] = float32(w/5)*0.2 + float32(x-3*w/5)*12 // a wall
|
||||
}
|
||||
// One river down the middle row of the plain.
|
||||
if y == h/2 && !sea[i] {
|
||||
flow[i] = 5e7
|
||||
}
|
||||
}
|
||||
}
|
||||
return GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, FlowM2: flow, Seed: 11}
|
||||
}
|
||||
|
||||
func genLegend(specs map[string]*GenSpec) *Legend {
|
||||
l := &Legend{
|
||||
MatchDistance: DefaultMatchDistance,
|
||||
MinAreaPx: DefaultMinAreaPx,
|
||||
Marks: []Mark{
|
||||
{Name: "forest", RGB: [3]int{0, 128, 0}},
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8},
|
||||
{Name: "wild_coast", RGB: [3]int{255, 128, 0}},
|
||||
{Name: "hand", RGB: [3]int{10, 10, 200}},
|
||||
},
|
||||
}
|
||||
for i := range l.Marks {
|
||||
if g, ok := specs[l.Marks[i].Name]; ok {
|
||||
l.Marks[i].Generate = g
|
||||
}
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// The property the whole feature rests on: generation never touches a pixel somebody painted. Without it,
|
||||
// re-running the generator would quietly destroy an author's work, and the round trip would be unusable.
|
||||
func TestGenerationNeverOverwritesPaintedPixels(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.9},
|
||||
"town": {Kind: GenSettlement, Count: 6, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 3},
|
||||
})
|
||||
|
||||
// A hand-painted stripe right across the plain, where the generator badly wants to put things.
|
||||
handIdx := uint8(l.Index("hand"))
|
||||
existing := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
|
||||
handAt := map[int]bool{}
|
||||
for y := 0; y < in.H; y++ {
|
||||
for x := in.W / 5; x < in.W/2; x += 3 {
|
||||
i := y*in.W + x
|
||||
existing.Mark[i] = handIdx
|
||||
handAt[i] = true
|
||||
}
|
||||
}
|
||||
in.Existing = existing
|
||||
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range handAt {
|
||||
if out.Mark[i] != handIdx {
|
||||
t.Fatalf("cell %d was painted %d by hand and the generator changed it to %d", i, handIdx, out.Mark[i])
|
||||
}
|
||||
}
|
||||
if rep.Kept != len(handAt) {
|
||||
t.Errorf("kept %d painted pixels, want %d", rep.Kept, len(handAt))
|
||||
}
|
||||
if rep.Painted == 0 {
|
||||
t.Error("the generator filled nothing at all; the test world should have room for every kind")
|
||||
}
|
||||
}
|
||||
|
||||
// A mark with no generate block is only ever painted by hand. This is what makes the feature opt-in and what
|
||||
// keeps every legend written before it producing exactly the blank sheet it always did.
|
||||
func TestMarksWithoutAGenerateBlockAreNeverGenerated(t *testing.T) {
|
||||
in := testWorld(160, 100)
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest, Cover: 0.8}})
|
||||
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forest := uint8(l.Index("forest"))
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && m != forest {
|
||||
t.Fatalf("cell %d got mark %d, but only %q asked to be generated", i, m, "forest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is ever put in the sea. A forest, a town or a road on open water is the one output that is simply
|
||||
// wrong rather than merely a matter of taste.
|
||||
func TestNothingIsGeneratedAtSea(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 1},
|
||||
"town": {Kind: GenSettlement, Count: 8, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 4},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && in.Sea[i] {
|
||||
t.Fatalf("cell %d is sea and was marked %d", i, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements keep their spacing, across tiers as well as within one. A village inside a city is two marks
|
||||
// for one place.
|
||||
func TestSettlementsKeepTheirSpacing(t *testing.T) {
|
||||
in := testWorld(300, 160)
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "city", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 3, MinSpacingKm: 5}},
|
||||
{Name: "village", RGB: [3]int{150, 90, 200}, Generate: &GenSpec{Kind: GenSettlement, Count: 12}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.Settlement) < 2 {
|
||||
t.Fatalf("only %d settlements placed; the test world should hold more", len(rep.Settlement))
|
||||
}
|
||||
minPx := 5 * 1000 / in.CellM
|
||||
for a := range rep.Settlement {
|
||||
for b := a + 1; b < len(rep.Settlement); b++ {
|
||||
p, q := rep.Settlement[a], rep.Settlement[b]
|
||||
dx := float64(wrapDelta(p.X-q.X, in.W))
|
||||
dy := float64(p.Y - q.Y)
|
||||
if d := math.Hypot(dx, dy); d < minPx-1e-9 {
|
||||
t.Fatalf("settlements %d and %d are %.1f px apart, closer than the %.1f px spacing", a, b, d, minPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roads connect only what is on the same landmass. Water is impassable, so a two-island world gets no road
|
||||
// between the islands however close they are.
|
||||
func TestRoadsNeverCrossWater(t *testing.T) {
|
||||
w, h := 240, 120
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
// Two flat islands with a channel between them.
|
||||
island := (x > 20 && x < 100) || (x > 140 && x < 220)
|
||||
if !island {
|
||||
sea[i] = true
|
||||
height[i] = -30
|
||||
} else {
|
||||
height[i] = 10
|
||||
}
|
||||
}
|
||||
}
|
||||
in := GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, Seed: 3}
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8, Generate: &GenSpec{Kind: GenRoad}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Towns landed on both islands, so a network that ignored water would have had a reason to cross.
|
||||
regions := map[int]bool{}
|
||||
for _, p := range rep.Settlement {
|
||||
regions[p.Region] = true
|
||||
}
|
||||
if len(regions) < 2 {
|
||||
t.Fatalf("settlements only landed on %d landmass(es); the test cannot show anything", len(regions))
|
||||
}
|
||||
road := uint8(l.Index("road"))
|
||||
for i, m := range out.Mark {
|
||||
if m == road && sea[i] {
|
||||
t.Fatalf("a road was painted at sea, cell %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same world and seed generate the same sheet. Determinism is cross-cutting rule 12 and it is what makes
|
||||
// a regenerated overlay reviewable in a diff.
|
||||
func TestGenerationIsDeterministic(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
a, repA, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, repB, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range a.Mark {
|
||||
if a.Mark[i] != b.Mark[i] {
|
||||
t.Fatalf("two runs disagree at cell %d: %d against %d", i, a.Mark[i], b.Mark[i])
|
||||
}
|
||||
}
|
||||
if repA.Painted != repB.Painted || len(repA.Settlement) != len(repB.Settlement) {
|
||||
t.Errorf("reports differ: %d/%d painted, %d/%d settlements",
|
||||
repA.Painted, repB.Painted, len(repA.Settlement), len(repB.Settlement))
|
||||
}
|
||||
}
|
||||
|
||||
// A generated sheet has to survive the round trip: encoded to RGBA and classified back, it must be the same
|
||||
// raster. If it did not, what the studio opened would not be what the generator wrote.
|
||||
func TestGeneratedSheetSurvivesClassifyingItBack(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
px, alpha := l.Encode(out)
|
||||
back, match := l.Classify(px, alpha, out.W, out.H)
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d pixels of a sheet this legend wrote matched no mark", match.Far)
|
||||
}
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != back.Mark[i] {
|
||||
t.Fatalf("round trip changed cell %d from %d to %d", i, out.Mark[i], back.Mark[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAnUnknownKind(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: "woods"}})
|
||||
if _, _, err := l.Generate(testWorld(80, 40)); err == nil {
|
||||
t.Fatal("a kind the generator does not know should be an error, not a silent no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAMismatchedExistingSheet(t *testing.T) {
|
||||
in := testWorld(80, 40)
|
||||
in.Existing = &Raster{W: 40, H: 20, Mark: make([]uint8, 800)}
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest}})
|
||||
if _, _, err := l.Generate(in); err == nil {
|
||||
t.Fatal("an existing sheet of the wrong size should be an error; it is registered to the template")
|
||||
}
|
||||
}
|
||||
|
||||
// A re-roll must actually re-roll. The studio's Generate button hands a fresh seed every press, and if the
|
||||
// placement does not move, the button does nothing an author can see: the forest count is a quantile and so
|
||||
// is invariant by construction, which makes the settlements the only visible difference between two drafts.
|
||||
func TestASecondSeedMovesTheSettlements(t *testing.T) {
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30},
|
||||
Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
place := func(seed int64) []Placed {
|
||||
in := testWorld(300, 160)
|
||||
in.Seed = seed
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rep.Settlement
|
||||
}
|
||||
a, b := place(11), place(20260920)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
t.Fatalf("no settlements placed (%d, %d); the test world should hold some", len(a), len(b))
|
||||
}
|
||||
same := 0
|
||||
for i := range a {
|
||||
if i < len(b) && a[i].X == b[i].X && a[i].Y == b[i].Y {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == len(a) && len(a) == len(b) {
|
||||
t.Fatalf("both seeds placed the same %d settlements in the same places; the seed is not reaching "+
|
||||
"the placement", len(a))
|
||||
}
|
||||
|
||||
// And the same seed twice is still the same world, or nothing is reproducible.
|
||||
c := place(11)
|
||||
for i := range a {
|
||||
if a[i].X != c[i].X || a[i].Y != c[i].Y {
|
||||
t.Fatalf("the same seed placed settlement %d differently on two runs", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user