Add serialization and deserialization to a compact byte-based format.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Manuel Friedli 2021-02-12 01:51:17 +01:00
parent 41330de4f4
commit 185114bcf4
7 changed files with 336 additions and 0 deletions

View file

@ -1,13 +1,17 @@
package ch.fritteli.labyrinth.generator.model; package ch.fritteli.labyrinth.generator.model;
import io.vavr.control.Option; import io.vavr.control.Option;
import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString;
import java.util.Deque; import java.util.Deque;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.Random; import java.util.Random;
@EqualsAndHashCode
@ToString
public class Labyrinth { public class Labyrinth {
private final Tile[][] field; private final Tile[][] field;
@Getter @Getter
@ -16,6 +20,7 @@ public class Labyrinth {
private final int height; private final int height;
@Getter @Getter
private final long randomSeed; private final long randomSeed;
@EqualsAndHashCode.Exclude
private final Random random; private final Random random;
@Getter @Getter
private final Position start; private final Position start;
@ -41,6 +46,25 @@ public class Labyrinth {
this.generate(); this.generate();
} }
/**
* INTERNAL API.
* Exists only for deserialization. Not to be called from user code.
*
* @param field
* @param width
* @param height
* @param randomSeed
*/
private Labyrinth(@NonNull final Tile[][] field, final int width, final int height, final long randomSeed) {
this.field = field;
this.width = width;
this.height = height;
this.randomSeed = randomSeed;
this.random = new Random(randomSeed);
this.start = new Position(0, 0);
this.end = new Position(this.width - 1, this.height - 1);
}
@NonNull @NonNull
public Option<Tile> getTileAt(@NonNull final Position position) { public Option<Tile> getTileAt(@NonNull final Position position) {
return this.getTileAt(position.getX(), position.getY()); return this.getTileAt(position.getX(), position.getY());

View file

@ -3,13 +3,18 @@ package ch.fritteli.labyrinth.generator.model;
import io.vavr.collection.Stream; import io.vavr.collection.Stream;
import io.vavr.control.Option; import io.vavr.control.Option;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import java.util.EnumSet;
import java.util.Random; import java.util.Random;
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@EqualsAndHashCode
@ToString
public class Tile { public class Tile {
final Walls walls = new Walls(); final Walls walls = new Walls();
boolean visited = false; boolean visited = false;
@ -20,6 +25,22 @@ public class Tile {
this.walls.setAll(); this.walls.setAll();
} }
/**
* INTERNAL API.
* Exists only for deserialization. Not to be called from user code.
*
* @param walls
* @param solution
*/
private Tile(@NonNull final EnumSet<Direction> walls, final boolean solution) {
for (final Direction direction : walls) {
this.walls.set(direction);
this.walls.harden(direction);
}
this.visited = true;
this.solution = solution;
}
public void preventDiggingToOrFrom(@NonNull final Direction direction) { public void preventDiggingToOrFrom(@NonNull final Direction direction) {
this.walls.harden(direction); this.walls.harden(direction);
} }

View file

@ -1,7 +1,9 @@
package ch.fritteli.labyrinth.generator.model; package ch.fritteli.labyrinth.generator.model;
import io.vavr.collection.Stream; import io.vavr.collection.Stream;
import lombok.EqualsAndHashCode;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashSet; import java.util.HashSet;
@ -9,6 +11,8 @@ import java.util.Set;
import java.util.SortedSet; import java.util.SortedSet;
import java.util.TreeSet; import java.util.TreeSet;
@EqualsAndHashCode
@ToString
public class Walls { public class Walls {
private final SortedSet<Direction> directions = new TreeSet<>(); private final SortedSet<Direction> directions = new TreeSet<>();
private final Set<Direction> hardened = new HashSet<>(); private final Set<Direction> hardened = new HashSet<>();

View file

@ -0,0 +1,36 @@
package ch.fritteli.labyrinth.generator.serialization;
import lombok.NonNull;
import java.io.ByteArrayInputStream;
public class LabyrinthInputStream extends ByteArrayInputStream {
public LabyrinthInputStream(@NonNull final byte[] buf) {
super(buf);
}
public byte readByte() {
final int read = this.read();
if (read == -1) {
// end of stream reached
throw new ArrayIndexOutOfBoundsException("End of stream reached. Cannot read more bytes.");
}
return (byte) read;
}
public int readInt() {
int result = 0;
result |= (0xff & this.readByte()) << 24;
result |= (0xff & this.readByte()) << 16;
result |= (0xff & this.readByte()) << 8;
result |= 0xff & this.readByte();
return result;
}
public long readLong() {
long result = 0;
result |= ((long) this.readInt()) << 32;
result |= 0xffffffffL & this.readInt();
return result;
}
}

View file

@ -0,0 +1,21 @@
package ch.fritteli.labyrinth.generator.serialization;
import java.io.ByteArrayOutputStream;
public class LabyrinthOutputStream extends ByteArrayOutputStream {
public void writeByte(final byte value) {
this.write(value);
}
public void writeInt(final int value) {
this.write(value >> 24);
this.write(value >> 16);
this.write(value >> 8);
this.write(value);
}
public void writeLong(final long value) {
this.writeInt((int) (value >> 32));
this.writeInt((int) value);
}
}

View file

@ -0,0 +1,197 @@
package ch.fritteli.labyrinth.generator.serialization;
import ch.fritteli.labyrinth.generator.model.Direction;
import ch.fritteli.labyrinth.generator.model.Labyrinth;
import ch.fritteli.labyrinth.generator.model.Tile;
import lombok.NonNull;
import lombok.experimental.UtilityClass;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.EnumSet;
/**
* <pre>
* decimal hex border
* 0 0 no border
* 1 1 top
* 2 2 right
* 3 3 top+right
* 4 4 bottom
* 5 5 top+bottom
* 6 6 right+bottom
* 7 7 top+right+bottom
* 8 8 left
* 9 9 top+left
* 10 a right+left
* 11 b top+right+left
* 12 c bottom+left
* 13 d top+bottom+left
* 14 e right+bottom+left
* 15 f top+right+bottom+left
* </pre>
* ==&gt; bits 0..2: always 0; bit 3: 1=solution, 0=not solution; bits 4..7: encode walls
* ==&gt; first bytes are:
* <pre>
* byte hex meaning
* 00 0x1a magic
* 01 0xb1 magic
* 02 0x00 version (0x00 -> dev / unstable; will be bumped to 0x01 once stabilized)
* 03..06 width (int)
* 07..10 height (int)
* 11..18 random seed number (long)
* 19.. tiles
* </pre>
* exteaneous space (poss. last nibble) is ignored.
*/
@UtilityClass
public class SerializerDeserializer {
private final byte MAGIC_BYTE_1 = 0x1a;
private final byte MAGIC_BYTE_2 = (byte) 0xb1;
private final byte VERSION_BYTE = 0x01;
private final byte TOP_BIT = 0b0000_0001;
private final byte RIGHT_BIT = 0b0000_0010;
private final byte BOTTOM_BIT = 0b0000_0100;
private final byte LEFT_BIT = 0b0000_1000;
private final byte SOLUTION_BIT = 0b0001_0000;
/**
* Serializes the {@code labyrinth} into a byte array.
*
* @param labyrinth The labyrinth to be serialized.
* @return The resulting byte array.
*/
@NonNull
public byte[] serialize(@NonNull final Labyrinth labyrinth) {
@NonNull final LabyrinthOutputStream stream = new LabyrinthOutputStream();
final int width = labyrinth.getWidth();
final int height = labyrinth.getHeight();
final long randomSeed = labyrinth.getRandomSeed();
stream.writeByte(MAGIC_BYTE_1);
stream.writeByte(MAGIC_BYTE_2);
stream.writeByte(VERSION_BYTE);
stream.writeLong(randomSeed);
stream.writeInt(width);
stream.writeInt(height);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// We .get() it, because we want to crash hard if it is not available.
@NonNull final Tile tile = labyrinth.getTileAt(x, y).get();
final byte bitmask = getBitmaskForTile(tile);
stream.writeByte(bitmask);
}
}
return stream.toByteArray();
}
/**
* Deserializes the byte array into an instance of {@link Labyrinth}.
*
* @param bytes The byte array to be deserialized.
* @return An instance of {@link Labyrinth}.
*/
@NonNull
public Labyrinth deserialize(@NonNull final byte[] bytes) {
final LabyrinthInputStream stream = new LabyrinthInputStream(bytes);
checkHeader(stream);
return readLabyrinthData(stream);
}
private static void checkHeader(@NonNull final LabyrinthInputStream stream) {
final byte magic1 = stream.readByte();
if (magic1 != MAGIC_BYTE_1) {
throw new IllegalArgumentException("Invalid labyrinth data.");
}
final byte magic2 = stream.readByte();
if (magic2 != MAGIC_BYTE_2) {
throw new IllegalArgumentException("Invalid labyrinth data.");
}
final int version = stream.readByte();
if (version != VERSION_BYTE) {
throw new IllegalArgumentException("Unknown Labyrinth data version: " + version);
}
}
@NonNull
private static Labyrinth readLabyrinthData(@NonNull final LabyrinthInputStream stream) {
final long randomSeed = stream.readLong();
final int width = stream.readInt();
final int height = stream.readInt();
@NonNull final Tile[][] tiles = new Tile[width][height];
for (int x = 0; x < width; x++) {
tiles[x] = new Tile[height];
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
final byte bitmask = stream.readByte();
tiles[x][y] = getTileForBitmask(bitmask);
}
}
return createLabyrinth(tiles, width, height, randomSeed);
}
@NonNull
private Labyrinth createLabyrinth(@NonNull final Tile[][] field, final int width, final int height, final long randomSeed) {
try {
@NonNull final Constructor<Labyrinth> constructor = Labyrinth.class.getDeclaredConstructor(Tile[][].class, Integer.TYPE, Integer.TYPE, Long.TYPE);
constructor.setAccessible(true);
return constructor.newInstance(field, width, height, randomSeed);
} catch (final NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
throw new RuntimeException("Can not deserialize Labyrinth from labyrinth data.", e);
}
}
@NonNull
private Tile createTile(@NonNull final EnumSet<Direction> walls, boolean solution) {
try {
@NonNull final Constructor<Tile> constructor = Tile.class.getDeclaredConstructor(EnumSet.class, Boolean.TYPE);
constructor.setAccessible(true);
return constructor.newInstance(walls, solution);
} catch (final NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException("Can not deserialize Tile from labyrinth data.", e);
}
}
private byte getBitmaskForTile(@NonNull final Tile tile) {
byte bitmask = 0;
if (tile.hasWallAt(Direction.TOP)) {
bitmask |= TOP_BIT;
}
if (tile.hasWallAt(Direction.RIGHT)) {
bitmask |= RIGHT_BIT;
}
if (tile.hasWallAt(Direction.BOTTOM)) {
bitmask |= BOTTOM_BIT;
}
if (tile.hasWallAt((Direction.LEFT))) {
bitmask |= LEFT_BIT;
}
if (tile.isSolution()) {
bitmask |= SOLUTION_BIT;
}
return bitmask;
}
@NonNull
private Tile getTileForBitmask(final byte bitmask) {
final EnumSet<Direction> walls = EnumSet.noneOf(Direction.class);
if ((bitmask & TOP_BIT) == TOP_BIT) {
walls.add(Direction.TOP);
}
if ((bitmask & RIGHT_BIT) == RIGHT_BIT) {
walls.add(Direction.RIGHT);
}
if ((bitmask & BOTTOM_BIT) == BOTTOM_BIT) {
walls.add(Direction.BOTTOM);
}
if ((bitmask & LEFT_BIT) == LEFT_BIT) {
walls.add(Direction.LEFT);
}
final boolean solution = (bitmask & SOLUTION_BIT) == SOLUTION_BIT;
return createTile(walls, solution);
}
}

View file

@ -0,0 +1,33 @@
package ch.fritteli.labyrinth.generator.serialization;
import ch.fritteli.labyrinth.generator.model.Labyrinth;
import lombok.NonNull;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SerializerDeserializerTest {
@Test
void testSerializeDeserializeTiny() {
@NonNull final Labyrinth expected = new Labyrinth(2, 2, 255);
@NonNull final byte[] bytes = SerializerDeserializer.serialize(expected);
@NonNull final Labyrinth result = SerializerDeserializer.deserialize(bytes);
assertEquals(expected, result);
}
@Test
void testSerializeDeserializeMedium() {
@NonNull final Labyrinth expected = new Labyrinth(20, 20, -271828182846L);
@NonNull final byte[] bytes = SerializerDeserializer.serialize(expected);
@NonNull final Labyrinth result = SerializerDeserializer.deserialize(bytes);
assertEquals(expected, result);
}
@Test
void testSerializeDeserializeLarge() {
@NonNull final Labyrinth expected = new Labyrinth(200, 320, 3141592653589793238L);
@NonNull final byte[] bytes = SerializerDeserializer.serialize(expected);
@NonNull final Labyrinth result = SerializerDeserializer.deserialize(bytes);
assertEquals(expected, result);
}
}