From 185114bcf402c821192e39430971b3c651c12d6b Mon Sep 17 00:00:00 2001 From: Manuel Friedli Date: Fri, 12 Feb 2021 01:51:17 +0100 Subject: [PATCH] Add serialization and deserialization to a compact byte-based format. --- .../labyrinth/generator/model/Labyrinth.java | 24 +++ .../labyrinth/generator/model/Tile.java | 21 ++ .../labyrinth/generator/model/Walls.java | 4 + .../serialization/LabyrinthInputStream.java | 36 ++++ .../serialization/LabyrinthOutputStream.java | 21 ++ .../serialization/SerializerDeserializer.java | 197 ++++++++++++++++++ .../SerializerDeserializerTest.java | 33 +++ 7 files changed, 336 insertions(+) create mode 100644 src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthInputStream.java create mode 100644 src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthOutputStream.java create mode 100644 src/main/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializer.java create mode 100644 src/test/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializerTest.java diff --git a/src/main/java/ch/fritteli/labyrinth/generator/model/Labyrinth.java b/src/main/java/ch/fritteli/labyrinth/generator/model/Labyrinth.java index 03b0e99..3390a3f 100644 --- a/src/main/java/ch/fritteli/labyrinth/generator/model/Labyrinth.java +++ b/src/main/java/ch/fritteli/labyrinth/generator/model/Labyrinth.java @@ -1,13 +1,17 @@ package ch.fritteli.labyrinth.generator.model; import io.vavr.control.Option; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; +import lombok.ToString; import java.util.Deque; import java.util.LinkedList; import java.util.Random; +@EqualsAndHashCode +@ToString public class Labyrinth { private final Tile[][] field; @Getter @@ -16,6 +20,7 @@ public class Labyrinth { private final int height; @Getter private final long randomSeed; + @EqualsAndHashCode.Exclude private final Random random; @Getter private final Position start; @@ -41,6 +46,25 @@ public class Labyrinth { 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 public Option getTileAt(@NonNull final Position position) { return this.getTileAt(position.getX(), position.getY()); diff --git a/src/main/java/ch/fritteli/labyrinth/generator/model/Tile.java b/src/main/java/ch/fritteli/labyrinth/generator/model/Tile.java index c7fb01b..b3e9735 100644 --- a/src/main/java/ch/fritteli/labyrinth/generator/model/Tile.java +++ b/src/main/java/ch/fritteli/labyrinth/generator/model/Tile.java @@ -3,13 +3,18 @@ package ch.fritteli.labyrinth.generator.model; import io.vavr.collection.Stream; import io.vavr.control.Option; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; +import lombok.ToString; import lombok.experimental.FieldDefaults; +import java.util.EnumSet; import java.util.Random; @FieldDefaults(level = AccessLevel.PRIVATE) +@EqualsAndHashCode +@ToString public class Tile { final Walls walls = new Walls(); boolean visited = false; @@ -20,6 +25,22 @@ public class Tile { 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 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) { this.walls.harden(direction); } diff --git a/src/main/java/ch/fritteli/labyrinth/generator/model/Walls.java b/src/main/java/ch/fritteli/labyrinth/generator/model/Walls.java index 0dced66..c7ce5e7 100644 --- a/src/main/java/ch/fritteli/labyrinth/generator/model/Walls.java +++ b/src/main/java/ch/fritteli/labyrinth/generator/model/Walls.java @@ -1,7 +1,9 @@ package ch.fritteli.labyrinth.generator.model; import io.vavr.collection.Stream; +import lombok.EqualsAndHashCode; import lombok.NonNull; +import lombok.ToString; import java.util.EnumSet; import java.util.HashSet; @@ -9,6 +11,8 @@ import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; +@EqualsAndHashCode +@ToString public class Walls { private final SortedSet directions = new TreeSet<>(); private final Set hardened = new HashSet<>(); diff --git a/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthInputStream.java b/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthInputStream.java new file mode 100644 index 0000000..1d13101 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthInputStream.java @@ -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; + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthOutputStream.java b/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthOutputStream.java new file mode 100644 index 0000000..6d2327f --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/generator/serialization/LabyrinthOutputStream.java @@ -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); + } +} diff --git a/src/main/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializer.java b/src/main/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializer.java new file mode 100644 index 0000000..3315202 --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializer.java @@ -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; + +/** + *
+ * 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
+ * 
+ * ==> bits 0..2: always 0; bit 3: 1=solution, 0=not solution; bits 4..7: encode walls + * ==> first bytes are: + *
+ *   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
+ * 
+ * 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 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 walls, boolean solution) { + try { + @NonNull final Constructor 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 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); + } +} diff --git a/src/test/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializerTest.java b/src/test/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializerTest.java new file mode 100644 index 0000000..4839e9b --- /dev/null +++ b/src/test/java/ch/fritteli/labyrinth/generator/serialization/SerializerDeserializerTest.java @@ -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); + } +}