diff --git a/.gitignore b/.gitignore
index 5459b6f0..0290202d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,8 @@ bin
.settings
.classpath
.project
-.idea
\ No newline at end of file
+.idea
+*.code-workspace
+
+# Generated resources
+src/main/resources/generated/*
diff --git a/build.gradle b/build.gradle
index ebff8ea2..bae0d8a0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -22,13 +22,12 @@ license {
header = project.file('LICENSE_HEADER')
ext.year = Calendar.getInstance().get(Calendar.YEAR)
mapping("java", "SLASHSTAR_STYLE")
+ exclude "**/*.json" // Exclude JSON to keep the font width data valid
}
tasks.withType(JavaCompile) {
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" << "-XDignore.symbol.file"
}
-// Run the license formatter before compiling the source code.
-tasks.compileJava.dependsOn licenseFormatMain, licenseFormatTest
configurations {
jaxDoclet
@@ -42,9 +41,24 @@ repositories {
maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
maven { url 'https://papermc.io/repo/repository/maven-public/' }
+
+ // Define a Ivy repo for the font width data (that way we don't need another plugin!)
+ def ddd_mc_font = ivy {
+ url 'https://dumbdogdiner.github.io/'
+ patternLayout { artifact '/[module]/[revision]/[classifier].[ext]'}
+ metadataSources { artifact() }
+ }
+ // Only use the Ivy repo for font width data - speeds up dependency resolution
+ exclusiveContent {
+ forRepositories(ddd_mc_font)
+ filter { includeGroup("dumbdogdiner") }
+ }
}
dependencies {
+ // Font width data (see above)
+ compile 'dumbdogdiner:mc-font-extractor:main:mojangles_width_data@json'
+
compileOnly 'org.projectlombok:lombok:1.18.16'
annotationProcessor 'org.projectlombok:lombok:1.18.16'
@@ -92,7 +106,6 @@ task sources(type: Jar, dependsOn: classes) {
// Some environments (such as the builder image) do not use UTF-8 as the default encoding!
// This sets UTF-8 as the encoding for the following tasks: delombok, compileJava, compileTestJava and javadoc.
delombok.encoding = "UTF-8"
-
compileJava.options.encoding = "UTF-8"
compileTestJava.options.encoding = "UTF-8"
javadoc.options.encoding = "UTF-8"
@@ -129,10 +142,23 @@ task processSourceTokens(type: Sync) {
// Use the filter task as the input for compileJava
compileJava.source = processSourceTokens.outputs
-
-tasks.publish.dependsOn build, sources
+// Font Width Info
+task copyMCFontExtractor(type: Copy) {
+ def path = project.configurations.compile.find {it.name.startsWith("mc-font-extractor") }
+ println path
+ from file(path)
+ // into file("src/main/resources")
+ // - Please keep this comment for future reference.
+ // - This is how we would do this if we weren't also adding build info (see processSourceTokens)
+ destinationDir file("src/main/resources/generated/")
+ rename 'mc-font-extractor-main-mojangles_width_data.json', 'mojangles_width_data.json'
+}
+// Run the license formatter and font data copier before compiling the source code.
+tasks.compileJava.dependsOn licenseFormatMain, licenseFormatTest, copyMCFontExtractor
+tasks.build.dependsOn sources
+tasks.publish.dependsOn build
publishing {
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/BookAndQuillBuilder.java b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/BookAndQuillBuilder.java
new file mode 100644
index 00000000..40fb01d0
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/BookAndQuillBuilder.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+package com.dumbdogdiner.stickyapi.bukkit.item;
+
+import com.dumbdogdiner.stickyapi.common.nbt.NbtCompoundTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtJsonTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtListTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtStringTag;
+import com.dumbdogdiner.stickyapi.common.util.TextUtil;
+import com.dumbdogdiner.stickyapi.common.util.StringUtil;
+import com.google.common.base.Preconditions;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.awt.print.Book;
+import java.util.Collections;
+import java.util.StringJoiner;
+
+/**
+ * This class allows for easy construction of a writable book, which is unfortunately much
+ * simpler than a written/signed book, either manually in code, or by loading a JSON object
+ *
+ * @since TBA
+ */
+
+@SuppressWarnings({"UnusedReturnValue", "unused"})
+@Accessors(chain = true)
+@NoArgsConstructor
+public class BookAndQuillBuilder {
+ private @NotNull JsonArray pages = new JsonArray();
+
+ @Setter
+ @Getter
+ private @Nullable String displayName;
+
+ private JsonArray lore = new JsonArray();
+
+ /**
+ * Creates a new BookGenerator from a given JsonObject, representing a book, which can
+ * be obtained in whatever way you want.
+ *
+ * @param bookObject A JSON object containing a JsonArray of valid Pages; If a {@link Material#WRITTEN_BOOK} is desired, it must contain a title, author, and optionally, a Generation (an integer with the value of 0, 1, 2, or 3, with 0 being the default)
+ * @return a {@link BookAndQuillBuilder} with the specified pages; if a title and author are both specified, it will be a {@link Material#WRITTEN_BOOK}, otherwise it will be a {@link Material#WRITABLE_BOOK}
+ */
+ public static @NotNull BookAndQuillBuilder fromJson(@NotNull JsonObject bookObject) {
+ BookAndQuillBuilder bookBuilder = new BookAndQuillBuilder();
+ JsonArray pages = bookObject.get("pages").getAsJsonArray();
+ Preconditions.checkNotNull(pages);
+ bookBuilder.pages = pages;
+ if(bookObject.has("lore") && bookObject.get("lore").isJsonArray()){
+ bookBuilder.lore = bookObject.get("lore").getAsJsonArray();
+ }
+ return bookBuilder;
+ }
+
+ /**
+ * Add some pages to the book. May fail with {@link IllegalStateException} if the book is full or fills up.
+ *
+ * @param pages Pages to add
+ * @return This object, for chaining
+ */
+ public @NotNull BookAndQuillBuilder addPages(String @NotNull ... pages) {
+ for (String page : pages) {
+ addPage(page);
+ }
+ return this;
+ }
+
+ /**
+ * Add a page to the book. May fail with {@link IllegalStateException} if the book is full.
+ *
+ * @param page The page to add
+ * @return This object, for chaining
+ */
+ public @NotNull BookAndQuillBuilder addPage(@NotNull String page) {
+ Preconditions.checkState(!isFull(), "Cannot add page, the book is overfilled!");
+ pages.add(page);
+ return this;
+ }
+
+ /**
+ * Build a book and quill from this generator.
+ *
+ * @return an {@link ItemStack} of the book, with pages and all other data
+ */
+ @SuppressWarnings("deprecation")
+ public @NotNull ItemStack toItemStack() {
+ Preconditions.checkState(pages.size() > 0, "Cannot generate book with no pages");
+ Preconditions.checkState(pages.size() < TextUtil.PAGES_PER_BOOK, "Cannot generate book with an invalid number of pages (must be less than " + TextUtil.PAGES_PER_BOOK + ")");
+ ItemStack stack = new ItemStack(Material.WRITABLE_BOOK, 1);
+
+ stack = Bukkit.getUnsafe().modifyItemStack(stack, generateNBT());
+
+ return stack;
+ }
+
+ /**
+ * @return The percentage of pages allowed that are used by this book.
+ * TODO: Does not account for characters per page or packet size at this time.
+ */
+ public float percentFull() {
+ return (float) pages.size() / (float) TextUtil.PAGES_PER_BOOK;
+ }
+
+ /**
+ * TODO: Does not account for characters per page or packet size at this time.
+ *
+ * @return True if the book is full.
+ */
+ public boolean isFull() {
+ return percentFull() >= 1.0f;
+ }
+
+ /**
+ * Adds a line of lore (formatted JSON text stuffs) to the book
+ */
+ public @NotNull BookAndQuillBuilder addLoreLine(JsonObject lore){
+ this.lore.add(lore);
+ return this;
+ }
+
+ public @NotNull BookAndQuillBuilder addLoreLines(JsonObject ... lores){
+ for(JsonObject lore : lores){
+ addLoreLine(lore);
+ }
+ return this;
+ }
+
+ /**
+ * Uses a {@link StringJoiner} to convert pages JsonArray to the weird NBT list
+ *
+ * @return {@link String} with NBT of the pages
+ */
+ @VisibleForTesting
+ public @NotNull String generateNBT() {
+ NbtCompoundTag bookNBT = new NbtCompoundTag();
+ bookNBT.put("pages", NbtListTag.fromJsonArray(this.pages));
+ NbtCompoundTag display = new NbtCompoundTag();
+ if(displayName != null)
+ display.put("Name", displayName);
+ if(lore.size() > 0){
+ display.put("Lore", NbtListTag.fromJsonArrayQuoted(lore));
+ }
+ if(!display.isEmpty())
+ bookNBT.put("display", display);
+ return bookNBT.toNbtString();
+ }
+}
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilder.java b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilder.java
new file mode 100644
index 00000000..31aae34d
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilder.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+package com.dumbdogdiner.stickyapi.bukkit.item;
+
+import com.dumbdogdiner.stickyapi.common.nbt.NbtCompoundTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtListTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtStringTag;
+import com.dumbdogdiner.stickyapi.common.util.TextUtil;
+import com.dumbdogdiner.stickyapi.common.util.StringUtil;
+import com.google.common.base.Preconditions;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.BookMeta;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.util.Collections;
+import java.util.StringJoiner;
+
+/**
+ * This class allows for easy construction of a Written Book, whether through JSON objects and manual
+ * creation, or by loading a full JSON object from a file or other source
+ *
+ * @since TBA
+ */
+@SuppressWarnings({"UnusedReturnValue", "unused"})
+@Accessors(chain = true)
+@NoArgsConstructor
+public class WrittenBookBuilder {
+ private @NotNull JsonArray pages = new JsonArray();
+ /**
+ * The generation of the book.
+ */
+ @Getter
+ @Setter
+ private @NonNull BookMeta.@NotNull Generation generation = BookMeta.Generation.ORIGINAL;
+
+ /**
+ * The author of the book. Must not be null if the book is written.
+ */
+ @Getter
+ @Setter
+ private @NotNull String author = StringUtil.randomObfuscatedString(1, 17, 4);
+
+ /**
+ * The title of the book, can be formatted using color codes.
+ */
+ @Getter
+ @Setter
+ private @NotNull String title = StringUtil.randomObfuscatedString(1, 17, 6);
+
+ /**
+ * Lore/hover text for the item
+ */
+ private @NotNull JsonArray lore = new JsonArray();
+
+ /**
+ * The book display name (In inventory)
+ */
+ @Getter
+ @Setter
+ private @Nullable String displayName;
+
+ /**
+ * Creates a new BookGenerator from a given JsonObject, representing a book, which can
+ * be obtained in whatever way you want.
+ *
+ * @param bookObject A JSON object containing a JsonArray of valid Pages; If a {@link Material#WRITTEN_BOOK} is desired, it must contain a title, author, and optionally, a Generation (an integer with the value of 0, 1, 2, or 3, with 0 being the default)
+ * @return a {@link WrittenBookBuilder} with the specified pages; if a title and author are both specified, it will be a {@link Material#WRITTEN_BOOK}, otherwise it will be a {@link Material#WRITABLE_BOOK}
+ */
+ public static @NotNull WrittenBookBuilder fromJson(@NotNull JsonObject bookObject) {
+ Preconditions.checkArgument(bookObject.has("pages") && bookObject.get("pages").isJsonArray(), "The provided JSON object must have a pages array!");
+ WrittenBookBuilder bookBuilder = new WrittenBookBuilder();
+ if (bookObject.has("author"))
+ bookBuilder.author = bookObject.get("author").getAsString();
+ if (bookObject.has("title"))
+ bookBuilder.title = bookObject.get("title").getAsString();
+ if (bookObject.has("generation"))
+ switch (bookObject.get("generation").getAsInt()) {
+ case 1:
+ bookBuilder.generation = BookMeta.Generation.COPY_OF_ORIGINAL;
+ break;
+ case 2:
+ bookBuilder.generation = BookMeta.Generation.COPY_OF_COPY;
+ break;
+ case 3:
+ bookBuilder .generation = BookMeta.Generation.TATTERED;
+ break;
+ case 0:
+ default:
+ bookBuilder.generation = BookMeta.Generation.ORIGINAL;
+ }
+ if(bookObject.has("lore") && bookObject.get("lore").isJsonArray())
+ bookBuilder.lore = bookObject.get("lore").getAsJsonArray();
+
+ bookBuilder.pages = bookObject.get("pages").getAsJsonArray();
+ return bookBuilder;
+ }
+
+ /**
+ * Adds a line of lore (formatted JSON text stuffs) to the book
+ */
+ public @NotNull WrittenBookBuilder addLoreLine(JsonObject lore){
+ this.lore.add(lore);
+ return this;
+ }
+
+ public @NotNull WrittenBookBuilder addLoreLines(JsonObject ... lores){
+ for(JsonObject lore : lores){
+ addLoreLine(lore);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a line of lore (formatted JSON text stuffs) to the book
+ */
+ public @NotNull WrittenBookBuilder addLoreLine(NbtStringTag lore){
+ this.lore.add(lore.toJson());
+ return this;
+ }
+
+ public @NotNull WrittenBookBuilder addLoreLines(NbtStringTag ... lores){
+ for(NbtStringTag lore : lores){
+ addLoreLine(lore);
+ }
+ return this;
+ }
+
+ /**
+ * Add some pages to the book. May fail with {@link IllegalStateException} if the book is full or fills up.
+ *
+ * @param pages Pages to add
+ * @return This object, for chaining
+ */
+ public @NotNull WrittenBookBuilder addPages(JsonObject @NotNull ... pages) {
+ for (JsonObject page : pages) {
+ addPage(page);
+ }
+ return this;
+ }
+
+ /**
+ * Add a page to the book. May fail with {@link IllegalStateException} if the book is full.
+ *
+ * @param page The page to add
+ * @return This object, for chaining
+ */
+ public @NotNull WrittenBookBuilder addPage(@NotNull JsonObject page) {
+ Preconditions.checkState(!isFull(), "Cannot add page, the book is overfilled!");
+ pages.add(page);
+ return this;
+ }
+
+ /**
+ * Build a book from this generator.
+ *
+ * @param quantity Quantity of the item stack.
+ * @return an {@link ItemStack} of the book, with pages and all other data
+ */
+ @SuppressWarnings("deprecation")
+ public @NotNull ItemStack toItemStack(int quantity) {
+ Preconditions.checkArgument(quantity > 0 && quantity <= 16, "Invalid quantity specified, quantity should be greater than 0 and less than or equal to 16, but was " + quantity);
+ Preconditions.checkState(pages.size() > 0, "Cannot generate book with no pages");
+ Preconditions.checkState(pages.size() < TextUtil.PAGES_PER_BOOK, "Cannot generate book with an invalid number of pages (must be less than " + TextUtil.PAGES_PER_BOOK + ")");
+ ItemStack stack = new ItemStack(Material.WRITTEN_BOOK, quantity);
+
+ stack = Bukkit.getUnsafe().modifyItemStack(stack, generateNBT());
+
+ return stack;
+ }
+
+ /**
+ * @return The percentage of pages allowed that are used by this book.
+ * TODO: Does not account for characters per page or packet size at this time.
+ */
+ public float percentFull() {
+ return (float) pages.size() / (float) TextUtil.PAGES_PER_BOOK;
+ }
+
+ /**
+ * TODO: Does not account for characters per page or packet size at this time.
+ *
+ * @return True if the book is full.
+ */
+ public boolean isFull() {
+ return percentFull() >= 1.0f;
+ }
+
+ /**
+ * Uses a {@link StringJoiner} to convert pages JsonArray to the weird NBT list
+ *
+ * @return {@link String} with NBT of the pages
+ */
+ @VisibleForTesting
+ public @NotNull String generateNBT() {
+ NbtCompoundTag bookNBT = new NbtCompoundTag();
+ bookNBT.put("author", author);
+ bookNBT.put("generation", getGenerationInt());
+ bookNBT.put("title", title);
+ NbtCompoundTag display = new NbtCompoundTag();
+ if(displayName != null)
+ display.put("Name", displayName);
+ if(lore.size() > 0){
+ display.put("Lore", NbtListTag.fromJsonArrayQuoted(lore));
+ }
+ if(!display.isEmpty())
+ bookNBT.put("display", display);
+ bookNBT.put("pages", NbtListTag.fromJsonArrayQuoted(pages));
+ return bookNBT.toNbtString();
+ }
+
+ /**
+ * Gets the generation as an int
+ */
+ private int getGenerationInt() {
+ switch (generation){
+ case ORIGINAL:
+ return 0;
+ case COPY_OF_ORIGINAL:
+ return 1;
+ case COPY_OF_COPY:
+ return 2;
+ case TATTERED:
+ return 3;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+}
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/package-info.java b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/package-info.java
new file mode 100644
index 00000000..1f5149b8
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/bukkit/item/package-info.java
@@ -0,0 +1,9 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+/**
+ * Classes related to item generation or manipulation
+ */
+
+package com.dumbdogdiner.stickyapi.bukkit.item;
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/MathUtil.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/MathUtil.java
index c5c66f72..f65d3b7f 100644
--- a/src/main/java/com/dumbdogdiner/stickyapi/common/util/MathUtil.java
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/MathUtil.java
@@ -4,9 +4,14 @@
*/
package com.dumbdogdiner.stickyapi.common.util;
+import com.google.common.primitives.Chars;
+import com.google.common.primitives.Ints;
+import org.jetbrains.annotations.NotNull;
+
import java.text.CharacterIterator;
import java.text.StringCharacterIterator;
import java.util.List;
+import java.util.Objects;
import java.util.Random;
/**
@@ -15,6 +20,8 @@
*
*/
public class MathUtil {
+ private MathUtil() {
+ }
private static final Random random = new Random();
@@ -35,13 +42,15 @@ public static int randomInt(int max) {
* Get a random number within a range
*
*
- * @param min minimum value
- * @param max maximum value
+ * @param min minimum value (inclusive)
+ * @param max maximum value (inclusive)
* @return a random integer within the specified range
* @throws IllegalArgumentException when min is greater than max
*/
public static int randomInt(int min, int max) {
- if (min >= max)
+ if (min == max)
+ return min;
+ if (min > max)
throw new IllegalArgumentException("Min may not be greater than max!");
return min + randomInt(1 + max - min);
}
@@ -82,8 +91,8 @@ public static double randomDouble(double min, double max) {
* @param array the array that should be used
* @return a random element from the specified array
*/
- public static T randomElement(T[] array) {
- if(array.length < 1) return null;
+ public static T randomElement(@NotNull T @NotNull [] array) {
+ if (array.length < 1) return null;
return array[randomInt(array.length)];
}
@@ -95,8 +104,8 @@ public static T randomElement(T[] array) {
* @param list the list that should be used
* @return a random element from the specified list
*/
- public static T randomElement(List list) {
- if(list.size() < 1) return null;
+ public static T randomElement(@NotNull List list) {
+ if (list.size() < 1) return null;
return list.get(randomInt(list.size()));
}
@@ -105,7 +114,7 @@ public static T randomElement(List list) {
* Round a double value
*
*
- * @param value the value that should be rounded
+ * @param value the value that should be rounded
* @param places amount of decimal places
* @return {@link Double}
*/
@@ -145,12 +154,25 @@ public static String bytesToReadable(long bytes) {
*
*
* @param number the number that should be tested
- * @param min minimum value
- * @param max maximum value
+ * @param min minimum value
+ * @param max maximum value
* @return true if given number is in range
*/
public static boolean inRange(int number, int min, int max) {
return number >= min && number <= max;
}
-}
\ No newline at end of file
+ /**
+ * @see #randomElement(List)
+ */
+ public static char randomElement(char @NotNull [] choices) {
+ return Objects.requireNonNull(randomElement(Chars.asList(choices)));
+ }
+
+ /**
+ * @see #randomElement(List)
+ */
+ public static int randomElement(int @NotNull [] choices) {
+ return Objects.requireNonNull(randomElement(Ints.asList(choices)));
+ }
+}
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/StringUtil.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/StringUtil.java
index 5a5dddfa..b289e5c5 100644
--- a/src/main/java/com/dumbdogdiner/stickyapi/common/util/StringUtil.java
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/StringUtil.java
@@ -9,6 +9,8 @@
import java.util.Map;
import java.util.UUID;
+import com.google.common.base.Preconditions;
+
import net.md_5.bungee.api.ChatColor;
import org.jetbrains.annotations.NotNull;
@@ -269,6 +271,21 @@ public static boolean startsWithIgnoreCase(@NotNull final String string, @NotNul
return string.regionMatches(true, 0, prefix, 0, prefix.length());
}
+ /**
+ * This method uses a region to check case-sensitive equality.
+ *
+ * @param string String to check
+ * @param prefix Prefix of string to compare
+ * @return {@link Boolean}
+ * @throws NullPointerException if prefix or string is null
+ */
+ public static boolean startsWith(@NotNull final String string, @NotNull final String prefix) {
+ if (string.length() < prefix.length()) {
+ return false;
+ }
+ return string.regionMatches(false, 0, prefix, 0, prefix.length());
+ }
+
/**
* Put hyphens into a uuid
*
@@ -277,7 +294,7 @@ public static boolean startsWithIgnoreCase(@NotNull final String string, @NotNul
*
* @param uuid to hyphenate
* @return {@link UUID}
- * @throws NullPointerException if uuid string is null
+ * @throws NullPointerException if uuid string is null
* @throws IllegalArgumentException if uuid is not 32 characters and is invalid
*/
public static UUID hyphenateUUID(@NotNull String uuid) {
@@ -291,13 +308,63 @@ public static UUID hyphenateUUID(@NotNull String uuid) {
}
/**
- * Replaces & followed by any valid minecraft format code (matching the regex
(?=([a-f]|[0-9]|[klmnor]))
with §
+ * Creates a random string of "magic" characters of characters
+ *
+ * @param min the minimum length of the string (inclusive)
+ * @param max the maximum length of the string (inclusive)
+ * @param minRunBeforeSpace Minimum number of characters before a space can
+ * appear, set to 0 to disable space
+ * @return A string of random characters, where the length
+ */
+ public static String randomObfuscatedString(int min, int max, int minRunBeforeSpace) {
+ Preconditions.checkArgument(max >= min, "Max cannot be less than min");
+ // Placeholder chars by width
+ char[] choices = new char[] {
+ // Space is boring
+ // ' ', // 1 px
+ 'i', // 2 px
+ 'l', // 3 px
+ 't', // 4 px
+ 'f', // 5 px
+ 'a', // 6 px
+ '@' // 7 px
+ };
+ StringBuilder obfuscated = new StringBuilder();
+
+ int len = MathUtil.randomInt(min, max);
+ int charsSinceSpace = 0;
+ while (obfuscated.length() < len) {
+ if (minRunBeforeSpace > 0 && charsSinceSpace > minRunBeforeSpace &&
+ // Set a 5% probability of the character being a space
+ MathUtil.randomInt(1, 100) <= 5) {
+ obfuscated.append(' ');
+ charsSinceSpace = 0;
+ } else {
+ obfuscated.append(MathUtil.randomElement(choices));
+ charsSinceSpace++;
+ }
+ }
+ obfuscated.insert(0, ChatColor.MAGIC);
+ obfuscated.append(ChatColor.RESET);
+ return obfuscated.toString();
+ }
+
+ /**
+ * Replaces & followed by any valid minecraft format code (matching the
+ * regex
+ *
+ *
+ * (?=([a-f]|[0-9]|[klmnor]))
+ *
+ *
+ * with §
*
* @param input The input string
- * @return A string where the relevant ampersands are replaced with section symbols
+ * @return A string where the relevant ampersands are replaced with section
+ * symbols
*/
public static String formatChatCodes(String input) {
return input.replaceAll("&(?=([a-f]|[0-9]|[klmnor]))", Character.toString(ChatColor.COLOR_CHAR));
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/TextUtil.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/TextUtil.java
index 9bdee829..2409e091 100644
--- a/src/main/java/com/dumbdogdiner/stickyapi/common/util/TextUtil.java
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/TextUtil.java
@@ -4,63 +4,150 @@
*/
package com.dumbdogdiner.stickyapi.common.util;
-import java.util.HashMap;
-
+import com.dumbdogdiner.stickyapi.StickyAPI;
+import com.google.common.base.Preconditions;
+import com.google.gson.Gson;
+import lombok.Data;
+import lombok.NonNull;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import net.md_5.bungee.api.ChatColor;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashMap;
+import java.util.Set;
+/**
+ * Utilities for text, such as in chat, books, and signs
+ *
+ * @since TBA (rewritten)
+ */
public class TextUtil {
- static HashMap characterWidths = new HashMap<>() {
- {
- put('!', 1);
- put(',', 1);
- put('\'', 1);
- put('.', 1);
- put(':', 1);
- put(';', 1);
- put('i', 1);
- put('|', 1);
- put('!', 1);
-
- put('`', 2);
- put('l', 2);
-
- put(' ', 3);
- put('(', 3);
- put(')', 3);
- put('*', 3);
- put('I', 3);
- put('[', 3);
- put(']', 3);
- put('t', 3);
- put('{', 3);
- put('}', 3);
-
- put('<', 4);
- put('>', 4);
- put('f', 4);
- put('k', 4);
-
- put('@', 6);
- put('~', 6);
+ private TextUtil() {
+ }
+
+ /** Offset for bold (in each direction) */
+ public static final float BOLD_OFFSET = 0.5f;
+ /** Offset for shadows (in each direction) */
+ public static final float SHADOW_OFFSET = 0.5f;
+
+ /** Number of pixels per line of a book */
+ public static final int PIXELS_PER_BOOK_LINE = 113;
+ /** Number of half-pixels per book line */
+ public static final int HALF_PIXELS_PER_BOOK_LINE = PIXELS_PER_BOOK_LINE * 2;
+
+ /** Number of lines in a page of a book */
+ public static final int LINES_PER_PAGE = 14;
+ /** Number of pages in a book */
+ public static final int PAGES_PER_BOOK = 50;
+
+ /** Number of pixels per line of a sign */
+ // TODO verify via minecraft source code
+ public static final int PIXELS_PER_SIGN_LINE = 96;
+ /** Number of lines per sign */
+ public static final int LINES_PER_SIGN = 4;
+
+ private static final HashMap widths = new HashMap<>();
+
+ /**
+ * Internal class to parse the width data json file
+ */
+ @Data
+ private static class WidthEntry {
+ private @NotNull String uid;
+ private @Nullable Integer id;
+ private @NotNull String val;
+ private int width;
+ }
+
+ static {
+ Gson gson = new Gson();
+ try (InputStream input = ClassLoader.getSystemResource("generated/mojangles_width_data.json").openStream()) {
+ WidthEntry[] entries = gson.fromJson(new InputStreamReader(input), WidthEntry[].class);
+ for (WidthEntry entry : entries) {
+ if (entry.getId() == null) entry.setId(0);
+ widths.put((char) (entry.getId() != null ? entry.getId() : '\0'), entry.width);
+ }
+ } catch (Exception e) {
+ StickyAPI.getLogger().severe(e.getMessage());
+ // fallback to a minimal configuration, in case anything fails
+ for (char c = 32; c <= 126; ++c) {
+ widths.put(c, 12);
+ }
+ // Extra fallback data to load in in case anything fails
+ // Minecraft font data is formatted in a very particular way
+ // so we are forced to use unfriendly code to load in extra fallback data.
+ "\0 ".chars().forEach(c -> widths.put((char) c, 2));
+ "!',.:;i|".chars().forEach(c -> widths.put((char) c, 4));
+ "`l".chars().forEach(c -> widths.put((char) c, 6));
+ // Some characters are represented by their Unicode code point to ensure they
+ // load correctly
+ "\"()*I[]t{}\u2022".chars().forEach(c -> widths.put((char) c, 8));
+ "<>fk\u00b7".chars().forEach(c -> widths.put((char) c, 10));
+ "@~".chars().forEach(c -> widths.put((char) c, 14));
}
- };
+ }
- // Uses info from:
/**
- * Get the width of a character (https://minecraft.gamepedia.com/Language#Font)
+ * Check if the given character is supported in-game.
*
- * @param c
- * @return
+ * @param c The character to check
+ * @return A boolean representing if the character is supported or not
*/
- public static int getCharacterWidth(@NotNull char c) {
- if (c < 32 || c > 126) {
- // Not presently implemented, would require rendering TTF
- return -1;
- }
+ public static boolean isCharacterSupported(char c) {
+ return widths.containsKey(c);
+ }
- if (characterWidths.containsKey(c)) {
- return characterWidths.get(c);
+
+ /**
+ * Returns a {@link Set} of {@link Character}of the supported characters
+ */
+ public static Set getSupportedCharacters() {
+ return widths.keySet();
+ }
+
+ /**
+ * @param c The character to measure
+ * @return The width of the character in half-pixels
+ * @throws IllegalArgumentException if the character is out of range
+ */
+ public static int getCharacterWidth(char c) throws IllegalArgumentException{
+ Preconditions.checkArgument(widths.containsKey(c), "Unsupported character: " + c + " code: " + String.format("%04x", (int) c));
+
+ return widths.get(c);
+ }
+
+ /**
+ * Measure the width of a string, assuming it is in the default Minecraft font.
+ *
+ * @param text The string to measure
+ * @param isBold If the string is bold
+ * @return The width of the string, in half-pixels
+ */
+ public static int getStringWidth(@NonNull String text, boolean isBold) {
+ text = ChatColor.stripColor(text);
+ int width = 0;
+
+ for (char c : text.toCharArray()) {
+ width += TextUtil.getCharacterWidth(c);
}
- return 5;
+
+ if (isBold)
+ width += 2 * text.length();
+
+ return width;
+ }
+
+ /**
+ Measure the width of a string, assuming it is in the default Minecraft font.
+ *
+ * @param text The string to measure
+ * @return The width of the string, in half-pixels
+ * @see #getStringWidth(String, boolean)
+ */
+ public static int getStringWidth(@NonNull String text) {
+ return getStringWidth(text, false);
}
}
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/reflection/package-info.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/reflection/package-info.java
new file mode 100644
index 00000000..5b9b0d6f
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/reflection/package-info.java
@@ -0,0 +1,8 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+/**
+ * Classes that make reflection a tad easier
+ */
+package com.dumbdogdiner.stickyapi.common.util.reflection;
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/URLPair.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/URLPair.java
index 3b400fbd..06d29054 100644
--- a/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/URLPair.java
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/URLPair.java
@@ -4,24 +4,18 @@
*/
package com.dumbdogdiner.stickyapi.common.util.url;
+import lombok.Data;
+
/**
* Sub-class for easier URL formatting between methods.
*/
+@Data
public class URLPair {
- String fullPath;
- String shortened;
+ private String fullPath;
+ private String shortened;
public URLPair(String fullUrl, String shortenedUrl) {
this.fullPath = fullUrl;
this.shortened = shortenedUrl;
}
-
- public String getShortened() {
- return shortened;
- }
-
- public String getFullPath() {
- return fullPath;
- }
-
}
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/package-info.java b/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/package-info.java
new file mode 100644
index 00000000..5cab60b2
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/common/util/url/package-info.java
@@ -0,0 +1,8 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+/**
+ * Utility classes for URLs
+ */
+package com.dumbdogdiner.stickyapi.common.util.url;
diff --git a/src/main/java/com/dumbdogdiner/stickyapi/package-info.java b/src/main/java/com/dumbdogdiner/stickyapi/package-info.java
new file mode 100644
index 00000000..827f5776
--- /dev/null
+++ b/src/main/java/com/dumbdogdiner/stickyapi/package-info.java
@@ -0,0 +1,11 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+/**
+ * StickyAPI
Utility methods, classes and potentially
+ * code-dupe-annihilating code for DDD plugins.
+ *
+ * @author DumbDogDiner (dumbdogdiner.com)
+ */
+package com.dumbdogdiner.stickyapi;
diff --git a/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/BookBuilderTest.java b/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/BookBuilderTest.java
new file mode 100644
index 00000000..6884d01a
--- /dev/null
+++ b/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/BookBuilderTest.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+package com.dumbdogdiner.stickyapi.bukkit.item;
+
+import org.junit.jupiter.api.Test;
+
+public class BookBuilderTest {
+ @Test
+ public void testJsonBuild(){
+ BookAndQuillBuilder bb = new BookAndQuillBuilder();
+ bb.addPage("Page1 is just a page\\n");
+ bb.addPages("And another page", "and yet another");
+ //noinspection RedundantArrayCreation
+ bb.addPages(new String [] {"yet more", "and more still"});
+
+ System.out.println("Pages NBT:");
+ System.out.println(bb.generateNBT());
+
+
+ System.out.println("Please paste the following line into a command block, and activate it");
+ System.out.println("give @p minecraft:writable_book" + bb.generateNBT());
+ }
+}
diff --git a/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilderTest.java b/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilderTest.java
new file mode 100644
index 00000000..0b4c012e
--- /dev/null
+++ b/src/test/java/com/dumbdogdiner/stickyapi/bukkit/item/WrittenBookBuilderTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+package com.dumbdogdiner.stickyapi.bukkit.item;
+
+import com.dumbdogdiner.stickyapi.common.nbt.NbtCompoundTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtJsonTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtListTag;
+import com.dumbdogdiner.stickyapi.common.nbt.NbtStringTag;
+import com.dumbdogdiner.stickyapi.common.util.StringUtil;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import org.junit.jupiter.api.*;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.text.MessageFormat;
+
+public class WrittenBookBuilderTest {
+ private static Reader bookReader;
+
+ private static Gson G;
+
+
+ @BeforeAll
+ static void setUp(){
+ G = new GsonBuilder().create();
+ }
+
+
+ @AfterEach
+ void tearDown() throws IOException {
+ bookReader.close();
+ System.gc();
+ }
+
+ @AfterAll
+ static void tearDownEnd() {
+ G = null;
+ System.gc();
+ }
+
+
+ @Test
+ public void testSimple() {
+ testJSON("/book1.json");
+ }
+
+ @Test
+ public void testProdRulebook() {
+ testJSON("/rulebook.json");
+ }
+
+ @Test
+ void testNbtStuff() {
+ bookReader = new InputStreamReader(WrittenBookBuilderTest.class.getResourceAsStream("/rulebook.json"));
+ JsonObject bookObject = JsonParser.parseReader(bookReader).getAsJsonObject();
+
+
+
+ NbtCompoundTag test123 = new NbtCompoundTag();
+ test123.put("title", new NbtStringTag(bookObject.get("title").getAsString()));
+ test123.put("author", NbtStringTag.fromPrimitive(bookObject.get("author").getAsJsonPrimitive()));
+ NbtListTag pages = new NbtListTag();
+ for(JsonElement element : bookObject.get("pages").getAsJsonArray()){
+ pages.add(new NbtJsonTag(element));
+ }
+ test123.put("pages", pages);
+ System.out.println("give @p minecraft:written_book" + test123.toNbtString());
+ }
+
+ public void testJSON(String filename) {
+ bookReader = new InputStreamReader(WrittenBookBuilderTest.class.getResourceAsStream(filename));
+ JsonObject bookObject = JsonParser.parseReader(bookReader).getAsJsonObject();
+ WrittenBookBuilder bb = WrittenBookBuilder.fromJson(bookObject);
+ System.out.println("Testing BookBuilder, please make sure the following NBT string is valid (It does not yet contain the title or author):");
+ bb.addLoreLines(new NbtStringTag("The rules owo"));
+ System.out.println(bb.generateNBT());
+
+ System.out.println("Please paste the following line into a command block, and activate it");
+ System.out.println("give @p minecraft:written_book" + bb.generateNBT());
+ }
+}
diff --git a/src/test/java/com/dumbdogdiner/stickyapi/common/util/StringUtilTest.java b/src/test/java/com/dumbdogdiner/stickyapi/common/util/StringUtilTest.java
index 2e01cb0b..bfb6db78 100644
--- a/src/test/java/com/dumbdogdiner/stickyapi/common/util/StringUtilTest.java
+++ b/src/test/java/com/dumbdogdiner/stickyapi/common/util/StringUtilTest.java
@@ -4,8 +4,11 @@
*/
package com.dumbdogdiner.stickyapi.common.util;
+import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
+import net.md_5.bungee.api.ChatColor;
+
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -219,4 +222,17 @@ public void testHyphenateUUIDNull() {
StringUtil.hyphenateUUID(null);
});
}
+
+ @RepeatedTest(100)
+ public void testRandomObfuscatedString() {
+ String rand = StringUtil.randomObfuscatedString(3,3, 0);
+ assertTrue(StringUtil.startsWith(rand, ChatColor.MAGIC.toString()));
+ System.out.println(rand);
+ assertEquals(3, ChatColor.stripColor(rand).length());
+
+ rand = StringUtil.randomObfuscatedString(1, 100, 0);
+ assertTrue(ChatColor.stripColor(rand).length() >= 1 && ChatColor.stripColor(rand).length() <= 100);
+ assertEquals(ChatColor.stripColor(rand).length(), rand.length() - 4);
+ System.out.println(rand);
+ }
}
diff --git a/src/test/java/com/dumbdogdiner/stickyapi/common/util/TextUtilTest.java b/src/test/java/com/dumbdogdiner/stickyapi/common/util/TextUtilTest.java
new file mode 100644
index 00000000..09bd8dce
--- /dev/null
+++ b/src/test/java/com/dumbdogdiner/stickyapi/common/util/TextUtilTest.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2020-2021 DumbDogDiner . All rights reserved.
+ * Licensed under the MIT license, see LICENSE for more information...
+ */
+package com.dumbdogdiner.stickyapi.common.util;
+
+import com.google.common.io.CharStreams;
+import org.junit.jupiter.api.Test;
+
+import java.text.MessageFormat;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+public class TextUtilTest {
+ @Test
+ public void testIsCharacterSupportedTrueBasicPlaneUtf16() {
+ assertTrue(TextUtil.isCharacterSupported((char) 0x0041));
+ }
+
+ @Test
+ public void testIsCharacterSupportedTrueBasicPlaneDecimal() {
+ assertTrue(TextUtil.isCharacterSupported((char) 65));
+ }
+
+ @Test
+ public void testIsCharacterSupportedTrueEmojiUtf16() {
+ // 0x1F5E1 | "Dagger Knife" Emoji 🗡️
+ assertTrue(TextUtil.isCharacterSupported((char) 0x1F5E1));
+ }
+
+ @Test
+ public void testIsCharacterSupportedFalseEmojiUtf16() {
+ // 0x1F98A | "Fox Face" Emoji 🦊
+ assertFalse(TextUtil.isCharacterSupported((char) 0x1F98A));
+ }
+
+ @Test
+ public void testAllSupportedCharsAreSupported() {
+ for(char c : TextUtil.getSupportedCharacters()){
+ assertTrue(TextUtil.isCharacterSupported(c));
+ }
+ }
+
+ @Test
+ public void testUnsupportedCharsNotSupported() {
+ Set unsupported = IntStream.range(Character.MIN_VALUE, Character.MAX_VALUE)
+ .boxed()
+ .map(i -> (char) (int) i)
+ .collect(Collectors.toSet());
+ unsupported.removeAll(TextUtil.getSupportedCharacters());
+ for (Character c : unsupported) {
+ try {
+ int x = TextUtil.getCharacterWidth(c);
+ fail(MessageFormat.format("Should have failed, instead got a width of {0} for char {1}", x, c));
+ } catch (IllegalArgumentException e) {
+ // expected
+ assertFalse(TextUtil.isCharacterSupported(c));
+ }
+ }
+ }
+}
diff --git a/src/test/resources/book1.json b/src/test/resources/book1.json
new file mode 100644
index 00000000..68e28d2f
--- /dev/null
+++ b/src/test/resources/book1.json
@@ -0,0 +1,25 @@
+{
+ "pages": [
+ [
+ {
+ "text": "Page 1\\nI'm text in "
+ },
+ {
+ "text": "bold",
+ "bold": true
+ },
+ {
+ "text": " owo",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "red text in page 2",
+ "color": "dark_red"
+ }
+ ]
+ ],
+ "title": "a booky book",
+ "author": "rodwuffy"
+}
diff --git a/src/test/resources/rulebook.json b/src/test/resources/rulebook.json
new file mode 100644
index 00000000..2dbc04f8
--- /dev/null
+++ b/src/test/resources/rulebook.json
@@ -0,0 +1,458 @@
+{
+ "pages": [
+ [
+ {
+ "text": "Dumb Dog Diner MC\\nSurvival Handbook",
+ "bold": true,
+ "italic": true,
+ "color": "dark_purple"
+ },
+ {
+ "text": "\\n\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Table of Contents:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "dark_red"
+ },
+ {
+ "text": "\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Page 2: ",
+ "color": "red"
+ },
+ {
+ "text": "Rules\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 3: ",
+ "color": "red"
+ },
+ {
+ "text": "Gameplay\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 5: ",
+ "color": "red"
+ },
+ {
+ "text": "Trading\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 7: ",
+ "color": "red"
+ },
+ {
+ "text": "Commands\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 9: ",
+ "color": "red"
+ },
+ {
+ "text": "Trading\\nCommands\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 11: ",
+ "color": "red"
+ },
+ {
+ "text": "Extras\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 12: ",
+ "color": "red"
+ },
+ {
+ "text": "Getting Help\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Pg. 14: ",
+ "color": "red"
+ },
+ {
+ "text": "Credits",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "The Rules:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "red"
+ },
+ {
+ "text": "\\n1. Absolutely no\\ngriefing, theft, or\\nintentional distress!\\n2. No cheating/hacking\\nallowed. No exploits.\\n3. No floating trees.\\n4. No fully-automatic\\nredstone farms.\\n5. No harassment,\\nbullying, or abuse.\\n6. No AFK fishing.",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "Gameplay:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "dark_green"
+ },
+ {
+ "text": "\\nThe world border for all worlds is 8,000\\nblocks from the\\ncenter. Because of\\nnow ",
+ "color": "reset"
+ },
+ {
+ "text": "Nether Portal",
+ "color": "dark_red"
+ },
+ {
+ "text": "\\ntravel works, you will\\nnot be able to light\\nthem past 1,000 blocks\\nfrom the center.\\nYou may build wherever you like! Try ",
+ "color": "reset"
+ },
+ {
+ "text": "/rtp",
+ "color": "blue"
+ },
+ {
+ "text": " to help find a spot!",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "This server uses a "
+ },
+ {
+ "text": "Death Chest ",
+ "color": "red"
+ },
+ {
+ "text": "system. Upon death, a campfire with your items will spawn at your death location. It will last\\nfor 10 minutes before\\nyour items drop on \\nthe ground!\\n\\nYou can have up to 3\\n",
+ "color": "reset"
+ },
+ {
+ "text": "Death Chests",
+ "color": "red"
+ },
+ {
+ "text": " at a time.",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "Trading:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "dark_aqua"
+ },
+ {
+ "text": "\\nWe use chest chops\\nfor player to trade\\ngood for ",
+ "color": "reset"
+ },
+ {
+ "text": "Coins",
+ "color": "gold"
+ },
+ {
+ "text": ".\\n\\nCoins may be earned through selling goods\\nto other players,\\npaying/receiving\\nmoney for a service,\\nor by selling goods to\\nthe server itself.",
+ "color": "reset"
+ }
+ ],
+ {
+ "text": "Chest shops can buy\\nor sell items!\\n\\nTo make a shop, place a chest and\\nshift-click with the\\nitem you want to sell\\nor buy.\\n\\nRegular users may have up to 6 shops\\nat a time."
+ },
+ [
+ {
+ "text": "Commands:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "red"
+ },
+ {
+ "text": "\\nTab-complete is set\\nup to only show commands you have access to. Here are some useful ones:\\n\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/helpmenu ",
+ "color": "blue"
+ },
+ {
+ "text": "- Opens the\\nhelp menu\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/servers ",
+ "color": "blue"
+ },
+ {
+ "text": "- Opens the\\nservers menu\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/rules",
+ "color": "blue"
+ },
+ {
+ "text": " - Grants this\\nbook",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "/options ",
+ "color": "blue"
+ },
+ {
+ "text": "- Opens the player options menu\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/tpa ",
+ "color": "blue"
+ },
+ {
+ "text": "- Sends a\\nteleport request to another player\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/rtp ",
+ "color": "blue"
+ },
+ {
+ "text": "- Start a\\nrandom teleport\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/pw ",
+ "color": "blue"
+ },
+ {
+ "text": "- Opens the playerwarps personal\\nhome menu\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/helpme ",
+ "color": "blue"
+ },
+ {
+ "text": "- Pings an online staff member\\nfor assistance",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "Trading Commands:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "dark_purple"
+ },
+ {
+ "text": "\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/bal ",
+ "color": "blue"
+ },
+ {
+ "text": "- Check your\\nbalance\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/pay ",
+ "color": "blue"
+ },
+ {
+ "text": "- Pay another player\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/sell ",
+ "color": "blue"
+ },
+ {
+ "text": "- Sell the item in your hand to the server\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/worth ",
+ "color": "blue"
+ },
+ {
+ "text": "- Shows the\\nitem price when selling\\nto the server",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "/value",
+ "color": "blue"
+ },
+ {
+ "text": " - Shows the\\naverage selling price\\nfor an item in\\nplayer shops",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "Extras:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "dark_aqua"
+ },
+ {
+ "text": "\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/donate, /vip ",
+ "color": "blue"
+ },
+ {
+ "text": "- Gives link to the server Patreon page\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/twitter, /twitch, /discord, /merch ",
+ "color": "blue"
+ },
+ {
+ "text": "\\n- Gives various social media links\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/afk ",
+ "color": "blue"
+ },
+ {
+ "text": "- Set your status to AFK\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/tamer tool ",
+ "color": "blue"
+ },
+ {
+ "text": "- Gives\\ntool to protect pets\\n ",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "Getting Help:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "light_purple"
+ },
+ {
+ "text": "\\nIf you ever need help\\ngetting your way\\naround the server,\\nplease ask any staff!\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/helpme",
+ "color": "blue"
+ },
+ {
+ "text": "\\n\\nPlease join the\\nDiscord server to get\\ntechnical help!\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/discord",
+ "color": "blue"
+ }
+ ],
+ [
+ {
+ "text": "Please report any\\nbugs, griefing, etc.\\nthat you come across\\nor are a victim of!",
+ "bold": true,
+ "underlined": true
+ },
+ {
+ "text": "\\n\\nUse ",
+ "color": "reset"
+ },
+ {
+ "text": "/report \u003cplayer\u003e",
+ "color": "blue"
+ },
+ {
+ "text": "\\nto report another\\nplayer to staff!\\n\\nThe help menu has shortcuts that make getting help easy!\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/help",
+ "color": "blue"
+ }
+ ],
+ [
+ {
+ "text": "Credits:",
+ "bold": true,
+ "italic": true,
+ "underlined": true,
+ "color": "gold"
+ },
+ {
+ "text": "\\n",
+ "color": "reset"
+ },
+ {
+ "text": "dddMC ",
+ "bold": true,
+ "color": "dark_purple"
+ },
+ {
+ "text": "is fully funded\\nthrough player donations. Please consider helping support us on Patreon!\\n",
+ "color": "reset"
+ },
+ {
+ "text": "/donate /patreon",
+ "color": "blue"
+ },
+ {
+ "text": "\\n\\nPatrons will receive a\\nVIP rank. More ranks\\nwill be added in the\\nfuture.",
+ "color": "reset"
+ }
+ ],
+ [
+ {
+ "text": "We hope you enjoy\\nyour time playing\\non the server!\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n- Stixil, "
+ },
+ {
+ "text": "dddMC 2020",
+ "bold": true,
+ "color": "dark_purple"
+ }
+ ]
+ ],
+ "title": "\u0026ddddMC Survival Handbook v1.0",
+ "author": "Stixil",
+ "lore" : [
+ {
+ "text": "dddMC 2020",
+ "bold": true,
+ "color": "dark_purple"
+ },
+ {
+ "text" : "",
+ "color": "reset"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/test/resources/rulebook.md b/src/test/resources/rulebook.md
new file mode 100644
index 00000000..16354a8b
--- /dev/null
+++ b/src/test/resources/rulebook.md
@@ -0,0 +1,97 @@
+# Dumb Dog Diner MC Survival Handbook
+
+## $c The Rules$
+
+1. Absolutely no griefing, theft, or intentional distress!
+2. No cheating/hacking allowed. Optifine and similar are okay.
+3. No floating trees.
+4. No fully-automatic redstone farms.
+5. No harassment, bullying, or abuse.
+6. No AFK fishing.
+
+---
+
+## $2 Gameplay$
+
+The world border for all worlds is 8,000 blocks from the center. Because of how
+$dark_red Nether Portal$ travel works, you will not be able to light them past 1,000 blocks
+from the center. You may build wherever you like! Try /rtp to help find a spot!
+
+This server uses a $red Death Chest$ system. Upon death, a campfire with your items
+will spawn at your death location. It will last for 10 minutes before your items
+drop on the ground! You can have up to 3 $red Death Chests$ at a time.
+
+---
+
+## $3 Trading$
+
+We use chest chops for player to trade good for $gold Coins$. Coins may be
+earned through selling goods to other players, paying/receiving money for a
+service, or by selling goods to the server itself.
+
+Chest shops can buy or sell items! To make a shop, place a chest and shift-click
+with the item you want to sell or buy. Regular users may have up to 6 shops at a
+time.
+
+---
+
+## $red Commands$
+
+Tab-complete is set up to only show commands you have access to. Here are some
+useful ones:
+
+- $blue /help$ - Opens the help menu
+- $blue /servers$ - Opens the server menu
+- $blue /rules$ - Grants this book
+- $blue /options$ - Opens the player options menu
+- $blue /tpa$ - Sends a teleport request to another player
+- $blue /rtp$ - Start a random teleport
+- $blue /pw$ - Opens the playerwarps personal home menu
+- $blue /helpme$ - Pings an online staff member for assistance
+
+---
+
+## $dark_purple Trading Commands$
+
+- $blue /bal$ - Check your balance
+- $blue /pay$ - Pay another player
+- $blue /sell$ - Sell the item in your hand to the server
+- $blue /worth$ - Shows the item price when selling to the server
+- $blue /value$ - Shows the average selling price for an item in player shops
+
+---
+
+## $dark_aqua Extra Commands$
+
+- $blue /donate, /vip$ - Gives link to the server Patreon page
+- $blue /discord$ - Gives link to the server Discord
+- $blue /twitter, /twitch$ - Gives social media links
+- $blue /merch$ - Gives merch link
+- $blue /afk$ - Set your status to AFK
+
+---
+
+## $light_purple Getting Help$
+
+If you ever need help getting your way around the server, please ask any staff!
+**$blue /helpme$**
+
+Please join the Discord server to get technical help! **$blue /discord$**
+
+Please report any bugs, griefing, etc. that you come across or are a victim of.
+A staff member will be happy to assist you. The help menu has shortcuts that
+make getting help easy! **$blue /help$**
+
+---
+
+## $gold Credits$
+
+$dark_purple **dddMC**$ is fully funded through player donations. Please consider helping
+support us on Patreon! $blue **/donate /patreon**$
+
+Patrons will receive a VIP rank. More
+ranks will be added in the future.
+
+We hope you enjoy your time playing on the server!
+
+\- Stixil, $dark_purple **dddMC 2020**$