doodle/pkg/level/chunk_test.go
Noah Petherbridge 5654145fd8 (Experimental) Run Length Encoding for Levels
Finally add a second option for Chunk MapAccessor implementation besides the
MapAccessor. The RLEAccessor is basically a MapAccessor that will compress
your drawing with Run Length Encoding (RLE) in the on-disk format in the ZIP
file.

This slashes the file sizes of most levels:

* Shapeshifter: 21.8 MB -> 8.1 MB
* Jungle: 10.4 MB -> 4.1 MB
* Zoo: 2.8 MB -> 1.3 MB

Implementation details:

* The RLE binary format for Chunks is a stream of Uvarint pairs storing the
  palette index number and the number of pixels to repeat it (along the Y,X
  axis of the chunk).
    * Null colors are represented by a Uvarint that decodes to 0xFFFF
      or 65535 in decimal.
    * Gameplay logic currently limits maps to 256 colors.
* The default for newly created chunks in-game will be RLE by default.
* Its in-memory representation is still a MapAccessor (a map of absolute
  world coordinates to palette index).
* The game can still open and play legacy MapAccessor maps.
* On save in the editor, the game will upgrade/convert MapAccessor chunks over
  to RLEAccessors, improving on your level's file size with a simple re-save.

Current Bugs

* On every re-save to RLE, one pixel is lost in the bottom-right corner of
  each chunk. Each subsequent re-save loses one more pixel to the left, so what
  starts as a single pixel per chunk slowly evolves into a horizontal line.
* Some pixels smear vertically as well.
* Off-by-negative-one errors when some chunks Iter() their pixels but compute
  a relative coordinate of (-1,0)! Some mismatch between the stored world coords
  of a pixel inside the chunk vs. the chunk's assigned coordinate by the Chunker:
  certain combinations of chunk coord/abs coord.

To Do

* The `doodad touch` command should re-save existing levels to upgrade them.
2024-05-23 23:02:01 -07:00

343 lines
7.1 KiB
Go

package level_test
import (
"fmt"
"testing"
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/go/render"
)
// Test the high level Chunker.
func TestChunker(t *testing.T) {
c := level.NewChunker(128)
// Test swatches.
var (
grey = &level.Swatch{
Name: "solid",
Color: render.Grey,
}
)
type testCase struct {
name string
run func() error
}
tests := []testCase{
testCase{
name: "Access a pixel on the blank map and expect an error",
run: func() error {
p := render.NewPoint(65535, -214564545)
_, err := c.Get(p)
if err == nil {
return fmt.Errorf("unexpected success getting point %s", p)
}
return nil
},
},
testCase{
name: "Set a pixel",
run: func() error {
// Set a point.
p := render.NewPoint(100, 200)
err := c.Set(p, grey)
if err != nil {
return fmt.Errorf("unexpected error getting point %s: %s", p, err)
}
return nil
},
},
testCase{
name: "Verify the set pixel",
run: func() error {
p := render.NewPoint(100, 200)
px, err := c.Get(p)
if err != nil {
return err
}
if px != grey {
return fmt.Errorf("pixel at %s not the expected color:\n"+
"Expected: %s\n"+
" Got: %s",
p,
grey,
px,
)
}
return nil
},
},
testCase{
name: "Verify the neighboring pixel is unset",
run: func() error {
p := render.NewPoint(101, 200)
_, err := c.Get(p)
if err == nil {
return fmt.Errorf("unexpected success getting point %s", p)
}
return nil
},
},
testCase{
name: "Delete the set pixel",
run: func() error {
p := render.NewPoint(100, 200)
err := c.Delete(p)
if err != nil {
return err
}
return nil
},
},
testCase{
name: "Verify the deleted pixel is unset",
run: func() error {
p := render.NewPoint(100, 200)
_, err := c.Get(p)
if err == nil {
return fmt.Errorf("unexpected success getting point %s", p)
}
return nil
},
},
testCase{
name: "Delete a pixel that didn't exist",
run: func() error {
p := render.NewPoint(-100, -100)
err := c.Delete(p)
if err == nil {
return fmt.Errorf("unexpected success deleting point %s", p)
}
return nil
},
},
}
for _, test := range tests {
if err := test.run(); err != nil {
t.Errorf("Failed: %s\n%s", test.name, err)
}
}
}
// Test the map chunk accessor.
func TestMapAccessor(t *testing.T) {
var (
c = level.NewChunk()
a = level.NewMapAccessor(c)
)
// Test action types
var (
Get = "Get"
Set = "Set"
Delete = "Delete"
)
// Test swatches.
var (
red = &level.Swatch{
Name: "fire",
Color: render.Red,
}
)
type testCase struct {
Action string
P render.Point
S *level.Swatch
Expect *level.Swatch
Err bool // expect error
}
tests := []testCase{
// Get a random point and expect to fail.
testCase{
Action: Get,
P: render.NewPoint(128, 128),
Err: true,
},
// Set a point.
testCase{
Action: Set,
S: red,
P: render.NewPoint(1024, 2048),
},
// Verify it exists.
testCase{
Action: Get,
P: render.NewPoint(1024, 2048),
Expect: red,
},
// A neighboring point does not exist.
testCase{
Action: Get,
P: render.NewPoint(1025, 2050),
Err: true,
},
// Delete a pixel that doesn't exist.
testCase{
Action: Delete,
P: render.NewPoint(1987, 2006),
Err: true,
},
// Delete one that does.
testCase{
Action: Delete,
P: render.NewPoint(1024, 2048),
},
// Verify gone
testCase{
Action: Get,
P: render.NewPoint(1024, 2048),
Err: true,
},
}
for _, test := range tests {
var px *level.Swatch
var err error
switch test.Action {
case Get:
px, err = a.Get(test.P)
case Set:
err = a.Set(test.P, test.S)
case Delete:
err = a.Delete(test.P)
}
if err != nil && !test.Err {
t.Errorf("unexpected error from %s %s: %s", test.Action, test.P, err)
continue
} else if err == nil && test.Err {
t.Errorf("didn't get error when we expected from %s %s", test.Action, test.P)
continue
}
if test.Action == Get {
if px != test.Expect {
t.Errorf("didn't get expected result\n"+
"Expected: %s\n"+
" Got: %s\n",
test.Expect,
px,
)
}
}
}
}
// Test the ChunkCoordinate function.
func TestChunkCoordinates(t *testing.T) {
c := level.NewChunker(128)
type testCase struct {
WorldCoordinate render.Point
ChunkCoordinate render.Point
RelativeCoordinate render.Point
}
tests := []testCase{
testCase{
WorldCoordinate: render.NewPoint(0, 0),
ChunkCoordinate: render.NewPoint(0, 0),
RelativeCoordinate: render.NewPoint(0, 0),
},
testCase{
WorldCoordinate: render.NewPoint(4, 8),
ChunkCoordinate: render.NewPoint(0, 0),
RelativeCoordinate: render.NewPoint(4, 8),
},
testCase{
WorldCoordinate: render.NewPoint(128, 128),
ChunkCoordinate: render.NewPoint(1, 1),
RelativeCoordinate: render.NewPoint(0, 0),
},
testCase{
WorldCoordinate: render.NewPoint(130, 156),
ChunkCoordinate: render.NewPoint(1, 1),
RelativeCoordinate: render.NewPoint(2, 28),
},
testCase{
WorldCoordinate: render.NewPoint(1024, 128),
ChunkCoordinate: render.NewPoint(8, 1),
RelativeCoordinate: render.NewPoint(0, 0),
},
testCase{
WorldCoordinate: render.NewPoint(3600, 1228),
ChunkCoordinate: render.NewPoint(28, 9),
RelativeCoordinate: render.NewPoint(16, 76),
},
testCase{
WorldCoordinate: render.NewPoint(-100, -1),
ChunkCoordinate: render.NewPoint(-1, -1),
RelativeCoordinate: render.NewPoint(28, 127),
},
testCase{
WorldCoordinate: render.NewPoint(-950, 100),
ChunkCoordinate: render.NewPoint(-8, 0),
RelativeCoordinate: render.NewPoint(74, 100),
},
testCase{
WorldCoordinate: render.NewPoint(-1001, -856),
ChunkCoordinate: render.NewPoint(-8, -7),
RelativeCoordinate: render.NewPoint(23, 40),
},
testCase{
WorldCoordinate: render.NewPoint(-3600, -4800),
ChunkCoordinate: render.NewPoint(-29, -38),
RelativeCoordinate: render.NewPoint(112, 64),
},
}
for _, test := range tests {
// Test conversion from world to chunk coordinate.
actual := c.ChunkCoordinate(test.WorldCoordinate)
if actual != test.ChunkCoordinate {
t.Errorf(
"Failed ChunkCoordinate conversion:\n"+
" Input: %s\n"+
"Expected: %s\n"+
" Got: %s",
test.WorldCoordinate,
test.ChunkCoordinate,
actual,
)
}
// Test the relative (inside-chunk) coordinate.
actual = level.RelativeCoordinate(test.WorldCoordinate, actual, c.Size)
if actual != test.RelativeCoordinate {
t.Errorf(
"Failed RelativeCoordinate conversion:\n"+
" Input: %s\n"+
"Expected: %s\n"+
" Got: %s",
test.WorldCoordinate,
test.RelativeCoordinate,
actual,
)
}
}
}
func TestZeroChunkSize(t *testing.T) {
c := &level.Chunker{}
coord := c.ChunkCoordinate(render.NewPoint(1200, 3600))
if !coord.IsZero() {
t.Errorf("ChunkCoordinate didn't fail with a zero chunk size!")
}
}