aboutsummaryrefslogtreecommitdiff
path: root/mod/src
diff options
context:
space:
mode:
Diffstat (limited to 'mod/src')
-rw-r--r--mod/src/main/java/moe/nea/ledger/init/AutoDiscoveryMixinPlugin.java193
-rw-r--r--mod/src/main/java/moe/nea/ledger/mixin/AccessorGuiEditSign.java12
-rw-r--r--mod/src/main/java/moe/nea/ledger/mixin/MouseClickEventPatch.java18
-rw-r--r--mod/src/main/java/moe/nea/ledger/mixin/OnInitializationCompletePatch.java18
-rw-r--r--mod/src/main/java/moe/nea/ledger/mixin/devenv/RegisterModResourcesPatch.java66
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ConfigCommand.kt31
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/DebouncedValue.kt38
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/DebugDataCommand.kt34
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/DevUtil.kt7
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ExpiringValue.kt30
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ItemChange.kt84
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ItemId.kt35
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ItemIdProvider.kt188
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ItemUtil.kt90
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/Ledger.kt205
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/LedgerEntry.kt29
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/LedgerLogger.kt136
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/LogChatCommand.kt27
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/MCUUIDUtil.kt22
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/NumberUtil.kt117
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/QueryCommand.kt197
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/ScoreboardUtil.kt29
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/TelemetryProvider.kt66
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/TransactionType.kt35
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/TriggerCommand.kt34
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/DebugOptions.kt13
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/LedgerConfig.kt35
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/MainOptions.kt27
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/SynchronizationOptions.kt11
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/UpdateUi.kt17
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/config/UpdateUiMarker.kt6
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/database/DBLogEntry.kt24
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/database/DBUpgrade.kt68
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/database/Database.kt57
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/database/Upgrades.kt20
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/database/schema.dot23
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/BeforeGuiAction.kt11
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/ChatReceived.kt15
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/ExtraSupplyIdEvent.kt12
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/GuiClickEvent.kt9
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/InitializationComplete.kt6
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/RegistrationFinishedEvent.kt7
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/SupplyDebugInfo.kt10
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/TriggerEvent.kt7
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/WorldLoadEvent.kt5
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/events/WorldSwitchEvent.kt6
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/AccessorySwapperDetection.kt34
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/AllowanceDetection.kt37
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/AuctionHouseDetection.kt143
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/BankDetection.kt49
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/BazaarDetection.kt58
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/BazaarOrderDetection.kt95
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/BitsDetection.kt62
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/BitsShopDetection.kt66
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/ChestDetection.kt48
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/DragonEyePlacementDetection.kt47
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/DragonSacrificeDetection.kt72
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/DungeonChestDetection.kt95
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/ExternalDataProvider.kt43
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/EyedropsDetection.kt35
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/ForgeDetection.kt48
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/GambleDetection.kt62
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionDetection.kt35
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionMixinDetection.kt38
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/KatDetection.kt95
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/KuudraChestDetection.kt45
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/MineshaftCorpseDetection.kt81
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/MinionDetection.kt61
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/NpcDetection.kt111
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/UpdateChecker.kt167
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/modules/VisitorDetection.kt87
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/BorderedTextTracker.kt41
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/ErrorUtil.kt52
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/GsonUtil.kt10
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/MinecraftExecutor.kt10
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/NoSideEffects.kt4
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/network/Request.kt40
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/network/RequestUtil.kt63
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/network/Response.kt19
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/BooleanContext.kt10
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/CommonKeys.kt9
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Context.kt57
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ContextValue.kt70
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/EventRecorder.kt9
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ExceptionContextValue.kt39
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/JsonElementContext.kt9
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/LoggingEventRecorder.kt25
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/RecordedEvent.kt5
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Severity.kt8
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Span.kt146
-rw-r--r--mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/StringContext.kt11
-rw-r--r--mod/src/main/resources/ledgerkeystore.jksbin0 -> 104393 bytes
-rw-r--r--mod/src/main/resources/mcmod.info18
-rw-r--r--mod/src/main/resources/mixins.moneyledger.json7
-rw-r--r--mod/src/test/kotlin/moe/nea/ledger/NumberUtilKtTest.kt17
95 files changed, 4523 insertions, 0 deletions
diff --git a/mod/src/main/java/moe/nea/ledger/init/AutoDiscoveryMixinPlugin.java b/mod/src/main/java/moe/nea/ledger/init/AutoDiscoveryMixinPlugin.java
new file mode 100644
index 0000000..56841b5
--- /dev/null
+++ b/mod/src/main/java/moe/nea/ledger/init/AutoDiscoveryMixinPlugin.java
@@ -0,0 +1,193 @@
+package moe.nea.ledger.init;
+
+import net.minecraft.launchwrapper.Launch;
+import org.spongepowered.asm.lib.tree.ClassNode;
+import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
+import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * A mixin plugin to automatically discover all mixins in the current JAR.
+ * <p>
+ * This mixin plugin automatically scans your entire JAR (or class directory, in case of an in-IDE launch) for classes inside of your
+ * mixin package and registers those. It does this recursively for sub packages of the mixin package as well. This means you will need
+ * to only have mixin classes inside of your mixin package, which is good style anyway.
+ *
+ * @author Linnea Gräf
+ */
+public class AutoDiscoveryMixinPlugin implements IMixinConfigPlugin {
+ private static final List<AutoDiscoveryMixinPlugin> mixinPlugins = new ArrayList<>();
+
+ public static List<AutoDiscoveryMixinPlugin> getMixinPlugins() {
+ return mixinPlugins;
+ }
+
+ private String mixinPackage;
+
+ @Override
+ public void onLoad(String mixinPackage) {
+ this.mixinPackage = mixinPackage;
+ mixinPlugins.add(this);
+ }
+
+ /**
+ * Resolves the base class root for a given class URL. This resolves either the JAR root, or the class file root.
+ * In either case the return value of this + the class name will resolve back to the original class url, or to other
+ * class urls for other classes.
+ */
+ public URL getBaseUrlForClassUrl(URL classUrl) {
+ String string = classUrl.toString();
+ if (classUrl.getProtocol().equals("jar")) {
+ try {
+ return new URL(string.substring(4).split("!")[0]);
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ if (string.endsWith(".class")) {
+ try {
+ return new URL(string.replace("\\", "/")
+ .replace(getClass().getCanonicalName()
+ .replace(".", "/") + ".class", ""));
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return classUrl;
+ }
+
+ /**
+ * Get the package that contains all the mixins. This value is set by mixin itself using {@link #onLoad}.
+ */
+ public String getMixinPackage() {
+ return mixinPackage;
+ }
+
+ /**
+ * Get the path inside the class root to the mixin package
+ */
+ public String getMixinBaseDir() {
+ return mixinPackage.replace(".", "/");
+ }
+
+ /**
+ * A list of all discovered mixins.
+ */
+ private List<String> mixins = null;
+
+ /**
+ * Try to add mixin class ot the mixins based on the filepath inside of the class root.
+ * Removes the {@code .class} file suffix, as well as the base mixin package.
+ * <p><b>This method cannot be called after mixin initialization.</p>
+ *
+ * @param className the name or path of a class to be registered as a mixin.
+ */
+ public void tryAddMixinClass(String className) {
+ String norm = (className.endsWith(".class") ? className.substring(0, className.length() - ".class".length()) : className)
+ .replace("\\", "/")
+ .replace("/", ".");
+ if (norm.startsWith(getMixinPackage() + ".") && !norm.endsWith(".")) {
+ mixins.add(norm.substring(getMixinPackage().length() + 1));
+ }
+ }
+
+ /**
+ * Search through the JAR or class directory to find mixins contained in {@link #getMixinPackage()}
+ */
+ @Override
+ public List<String> getMixins() {
+ if (mixins != null) return mixins;
+ System.out.println("Trying to discover mixins");
+ mixins = new ArrayList<>();
+ URL classUrl = getClass().getProtectionDomain().getCodeSource().getLocation();
+ System.out.println("Found classes at " + classUrl);
+ Path file;
+ try {
+ file = Paths.get(getBaseUrlForClassUrl(classUrl).toURI());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ System.out.println("Base directory found at " + file);
+ if (Files.isDirectory(file)) {
+ walkDir(file);
+ } else {
+ walkJar(file);
+ }
+ System.out.println("Found mixins: " + mixins);
+
+ if (!(Boolean) Launch.blackboard.get("fml.deobfuscatedEnvironment")) {
+ mixins.removeIf(it -> it.contains("devenv"));
+ }
+
+ return mixins;
+ }
+
+ /**
+ * Search through directory for mixin classes based on {@link #getMixinBaseDir}.
+ *
+ * @param classRoot The root directory in which classes are stored for the default package.
+ */
+ private void walkDir(Path classRoot) {
+ System.out.println("Trying to find mixins from directory");
+ try (Stream<Path> classes = Files.walk(classRoot.resolve(getMixinBaseDir()))) {
+ classes.map(it -> classRoot.relativize(it).toString())
+ .forEach(this::tryAddMixinClass);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Read through a JAR file, trying to find all mixins inside.
+ */
+ private void walkJar(Path file) {
+ System.out.println("Trying to find mixins from jar file");
+ try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(file))) {
+ ZipEntry next;
+ while ((next = zis.getNextEntry()) != null) {
+ tryAddMixinClass(next.getName());
+ zis.closeEntry();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+
+ }
+
+ @Override
+ public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+
+ }
+
+ @Override
+ public String getRefMapperConfig() {
+ return null;
+ }
+
+ @Override
+ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
+ return true;
+ }
+
+ @Override
+ public void acceptTargets(Set<String> myTargets, Set<String> otherTargets) {
+
+ }
+}
diff --git a/mod/src/main/java/moe/nea/ledger/mixin/AccessorGuiEditSign.java b/mod/src/main/java/moe/nea/ledger/mixin/AccessorGuiEditSign.java
new file mode 100644
index 0000000..52b8911
--- /dev/null
+++ b/mod/src/main/java/moe/nea/ledger/mixin/AccessorGuiEditSign.java
@@ -0,0 +1,12 @@
+package moe.nea.ledger.mixin;
+
+import net.minecraft.client.gui.inventory.GuiEditSign;
+import net.minecraft.tileentity.TileEntitySign;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(GuiEditSign.class)
+public interface AccessorGuiEditSign {
+ @Accessor("tileSign")
+ TileEntitySign getTileEntity_ledger();
+}
diff --git a/mod/src/main/java/moe/nea/ledger/mixin/MouseClickEventPatch.java b/mod/src/main/java/moe/nea/ledger/mixin/MouseClickEventPatch.java
new file mode 100644
index 0000000..4e6e360
--- /dev/null
+++ b/mod/src/main/java/moe/nea/ledger/mixin/MouseClickEventPatch.java
@@ -0,0 +1,18 @@
+package moe.nea.ledger.mixin;
+
+import moe.nea.ledger.events.GuiClickEvent;
+import net.minecraft.client.gui.inventory.GuiContainer;
+import net.minecraft.inventory.Slot;
+import net.minecraftforge.common.MinecraftForge;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(GuiContainer.class)
+public class MouseClickEventPatch {
+ @Inject(method = "handleMouseClick", at = @At("HEAD"))
+ private void onHandleMouseClick(Slot slotIn, int slotId, int clickedButton, int clickType, CallbackInfo ci) {
+ MinecraftForge.EVENT_BUS.post(new GuiClickEvent(slotIn, slotId, clickedButton, clickType));
+ }
+}
diff --git a/mod/src/main/java/moe/nea/ledger/mixin/OnInitializationCompletePatch.java b/mod/src/main/java/moe/nea/ledger/mixin/OnInitializationCompletePatch.java
new file mode 100644
index 0000000..fc9afb7
--- /dev/null
+++ b/mod/src/main/java/moe/nea/ledger/mixin/OnInitializationCompletePatch.java
@@ -0,0 +1,18 @@
+package moe.nea.ledger.mixin;
+
+import moe.nea.ledger.events.InitializationComplete;
+import net.minecraft.client.Minecraft;
+import net.minecraftforge.common.MinecraftForge;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(Minecraft.class)
+public class OnInitializationCompletePatch {
+
+ @Inject(method = "startGame", at = @At(value = "INVOKE", target = "Lnet/minecraftforge/fml/client/FMLClientHandler;onInitializationComplete()V"))
+ private void onInitComplete(CallbackInfo ci) {
+ MinecraftForge.EVENT_BUS.post(new InitializationComplete());
+ }
+}
diff --git a/mod/src/main/java/moe/nea/ledger/mixin/devenv/RegisterModResourcesPatch.java b/mod/src/main/java/moe/nea/ledger/mixin/devenv/RegisterModResourcesPatch.java
new file mode 100644
index 0000000..88e8364
--- /dev/null
+++ b/mod/src/main/java/moe/nea/ledger/mixin/devenv/RegisterModResourcesPatch.java
@@ -0,0 +1,66 @@
+package moe.nea.ledger.mixin.devenv;
+
+import com.google.common.eventbus.EventBus;
+import net.minecraftforge.fml.client.FMLFileResourcePack;
+import net.minecraftforge.fml.common.DummyModContainer;
+import net.minecraftforge.fml.common.LoadController;
+import net.minecraftforge.fml.common.ModContainer;
+import net.minecraftforge.fml.common.ModMetadata;
+import net.minecraftforge.fml.common.discovery.ASMDataTable;
+import net.minecraftforge.fml.common.discovery.ContainerType;
+import net.minecraftforge.fml.common.discovery.ModCandidate;
+import net.minecraftforge.fml.common.discovery.ModDiscoverer;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.List;
+
+@Mixin(value = ModDiscoverer.class, remap = false)
+public class RegisterModResourcesPatch {
+ @Shadow
+ private List<ModCandidate> candidates;
+
+ @Inject(method = "identifyMods", at = @At("HEAD"), remap = false)
+ private void addCandidate(CallbackInfoReturnable<List<ModContainer>> cir) {
+ String bonusResourceMod = System.getProperty("ledger.bonusresourcemod");
+ if (bonusResourceMod == null) return;
+ File file = new File(bonusResourceMod);
+ if (!file.isDirectory()) return;
+ ModMetadata modMetadata = new ModMetadata();
+ modMetadata.modId = "ledger-bonus";
+ modMetadata.name = "Ledger Bonus Resources";
+ modMetadata.autogenerated = true;
+ ModContainer container = new DummyModContainer(modMetadata) {
+ @Override
+ public Object getMod() {
+ return new Object();
+ }
+
+ @Override
+ public boolean registerBus(EventBus bus, LoadController controller) {
+ return true;
+ }
+
+ @Override
+ public File getSource() {
+ return file;
+ }
+
+ @Override
+ public Class<?> getCustomResourcePackClass() {
+ return FMLFileResourcePack.class;
+ }
+ };
+ candidates.add(new ModCandidate(file, file, ContainerType.DIR) {
+ @Override
+ public List<ModContainer> explore(ASMDataTable table) {
+ return Collections.singletonList(container);
+ }
+ });
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ConfigCommand.kt b/mod/src/main/kotlin/moe/nea/ledger/ConfigCommand.kt
new file mode 100644
index 0000000..5b964c8
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ConfigCommand.kt
@@ -0,0 +1,31 @@
+package moe.nea.ledger
+
+import io.github.notenoughupdates.moulconfig.common.IMinecraft
+import net.minecraft.command.CommandBase
+import net.minecraft.command.ICommandSender
+
+class ConfigCommand : CommandBase() {
+ override fun canCommandSenderUseCommand(sender: ICommandSender?): Boolean {
+ return true
+ }
+
+ override fun getCommandName(): String {
+ return "ledgerconfig"
+ }
+
+ override fun getCommandUsage(sender: ICommandSender?): String {
+ return ""
+ }
+
+ override fun processCommand(sender: ICommandSender?, args: Array<out String>) {
+ val editor = Ledger.managedConfig.getEditor()
+ editor.search(args.joinToString(" "))
+ Ledger.runLater {
+ IMinecraft.instance.openWrappedScreen(editor)
+ }
+ }
+
+ override fun getCommandAliases(): List<String> {
+ return listOf("moneyledger")
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/DebouncedValue.kt b/mod/src/main/kotlin/moe/nea/ledger/DebouncedValue.kt
new file mode 100644
index 0000000..66fba8d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/DebouncedValue.kt
@@ -0,0 +1,38 @@
+package moe.nea.ledger
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.nanoseconds
+import kotlin.time.Duration.Companion.seconds
+
+class DebouncedValue<T>(private val value: T) {
+ companion object {
+ fun <T> farFuture(): DebouncedValue<T> {
+ val value = DebouncedValue(Unit)
+ value.take()
+ @Suppress("UNCHECKED_CAST")
+ return value as DebouncedValue<T>
+ }
+ }
+
+ val lastSeenAt = System.nanoTime()
+ val age get() = (System.nanoTime() - lastSeenAt).nanoseconds
+ var taken = false
+ private set
+
+ fun get(debounce: Duration): T? {
+ return if (!taken && age >= debounce) value
+ else null
+ }
+
+ fun replace(): T? {
+ return consume(0.seconds)
+ }
+
+ fun consume(debounce: Duration): T? {
+ return get(debounce)?.also { take() }
+ }
+
+ fun take() {
+ taken = true
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/DebugDataCommand.kt b/mod/src/main/kotlin/moe/nea/ledger/DebugDataCommand.kt
new file mode 100644
index 0000000..bab0a78
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/DebugDataCommand.kt
@@ -0,0 +1,34 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.events.SupplyDebugInfo
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.command.CommandBase
+import net.minecraft.command.ICommandSender
+import net.minecraftforge.common.MinecraftForge
+
+class DebugDataCommand : CommandBase() {
+
+ override fun canCommandSenderUseCommand(sender: ICommandSender?): Boolean {
+ return true
+ }
+
+ override fun getCommandName(): String {
+ return "ledgerdebug"
+ }
+
+ override fun getCommandUsage(sender: ICommandSender?): String {
+ return ""
+ }
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ override fun processCommand(sender: ICommandSender?, args: Array<out String>?) {
+ val debugInfo = SupplyDebugInfo()
+ MinecraftForge.EVENT_BUS.post(debugInfo)
+ logger.printOut("Collected debug info:")
+ debugInfo.data.forEach {
+ logger.printOut("${it.first}: ${it.second}")
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/DevUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/DevUtil.kt
new file mode 100644
index 0000000..d0dd653
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/DevUtil.kt
@@ -0,0 +1,7 @@
+package moe.nea.ledger
+
+import net.minecraft.launchwrapper.Launch
+
+object DevUtil {
+ val isDevEnv = Launch.blackboard["fml.deobfuscatedEnvironment"] as Boolean
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ExpiringValue.kt b/mod/src/main/kotlin/moe/nea/ledger/ExpiringValue.kt
new file mode 100644
index 0000000..b50b14e
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ExpiringValue.kt
@@ -0,0 +1,30 @@
+package moe.nea.ledger
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.nanoseconds
+
+class ExpiringValue<T>(private val value: T) {
+ val lastSeenAt: Long = System.nanoTime()
+ val age get() = (System.nanoTime() - lastSeenAt).nanoseconds
+ var taken = false
+ private set
+
+ fun get(expiry: Duration): T? {
+ return if (!taken && age < expiry) value
+ else null
+ }
+
+ companion object {
+ fun <T> empty(): ExpiringValue<T> {
+ val value = ExpiringValue(Unit)
+ value.take()
+ @Suppress("UNCHECKED_CAST")
+ return value as ExpiringValue<T>
+ }
+ }
+
+ fun consume(expiry: Duration): T? = get(expiry)?.also { take() }
+ fun take() {
+ taken = true
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ItemChange.kt b/mod/src/main/kotlin/moe/nea/ledger/ItemChange.kt
new file mode 100644
index 0000000..fda709c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ItemChange.kt
@@ -0,0 +1,84 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.database.DBItemEntry
+import moe.nea.ledger.database.ResultRow
+import net.minecraft.event.HoverEvent
+import net.minecraft.util.ChatComponentText
+import net.minecraft.util.ChatStyle
+import net.minecraft.util.EnumChatFormatting
+import net.minecraft.util.IChatComponent
+
+data class ItemChange(
+ val itemId: ItemId,
+ val count: Double,
+ val direction: ChangeDirection,
+) {
+ fun formatChat(): IChatComponent {
+ return ChatComponentText(" ")
+ .appendSibling(direction.chatFormat)
+ .appendText(" ")
+ .appendSibling(ChatComponentText("$count").setChatStyle(ChatStyle().setColor(EnumChatFormatting.WHITE)))
+ .appendSibling(ChatComponentText("x").setChatStyle(ChatStyle().setColor(EnumChatFormatting.DARK_GRAY)))
+ .appendText(" ")
+ .appendSibling(ChatComponentText(itemId.string).setChatStyle(ChatStyle().setParentStyle(ChatStyle().setColor(
+ EnumChatFormatting.WHITE))))
+ }
+
+ enum class ChangeDirection {
+ GAINED,
+ TRANSFORM,
+ SYNC,
+ CATALYST,
+ LOST;
+
+ val chatFormat by lazy { formatChat0() }
+ private fun formatChat0(): IChatComponent {
+ val (text, color) = when (this) {
+ GAINED -> "+" to EnumChatFormatting.GREEN
+ TRANSFORM -> "~" to EnumChatFormatting.YELLOW
+ SYNC -> "=" to EnumChatFormatting.BLUE
+ CATALYST -> "*" to EnumChatFormatting.DARK_PURPLE
+ LOST -> "-" to EnumChatFormatting.RED
+ }
+ return ChatComponentText(text)
+ .setChatStyle(
+ ChatStyle()
+ .setColor(color)
+ .setChatHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT,
+ ChatComponentText(name).setChatStyle(ChatStyle().setColor(color)))))
+ }
+ }
+
+ companion object {
+ fun gainCoins(number: Double): ItemChange {
+ return gain(ItemId.COINS, number)
+ }
+
+ fun unpair(direction: ChangeDirection, pair: Pair<ItemId, Double>): ItemChange {
+ return ItemChange(pair.first, pair.second, direction)
+ }
+
+ fun unpairGain(pair: Pair<ItemId, Double>) = unpair(ChangeDirection.GAINED, pair)
+ fun unpairLose(pair: Pair<ItemId, Double>) = unpair(ChangeDirection.LOST, pair)
+
+ fun gain(itemId: ItemId, amount: Number): ItemChange {
+ return ItemChange(itemId, amount.toDouble(), ChangeDirection.GAINED)
+ }
+
+ fun lose(itemId: ItemId, amount: Number): ItemChange {
+ return ItemChange(itemId, amount.toDouble(), ChangeDirection.LOST)
+ }
+
+ fun loseCoins(number: Double): ItemChange {
+ return lose(ItemId.COINS, number)
+ }
+
+ fun from(result: ResultRow): ItemChange {
+ return ItemChange(
+ result[DBItemEntry.itemId],
+ result[DBItemEntry.size],
+ result[DBItemEntry.mode],
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ItemId.kt b/mod/src/main/kotlin/moe/nea/ledger/ItemId.kt
new file mode 100644
index 0000000..8211cd3
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ItemId.kt
@@ -0,0 +1,35 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.utils.NoSideEffects
+
+data class ItemId(
+ val string: String
+) {
+ @NoSideEffects
+ fun singleItem(): Pair<ItemId, Double> {
+ return withStackSize(1)
+ }
+
+ @NoSideEffects
+ fun withStackSize(size: Number): Pair<ItemId, Double> {
+ return Pair(this, size.toDouble())
+ }
+
+
+ companion object {
+
+ @JvmStatic
+ @NoSideEffects
+ fun forName(string: String) = ItemId(string)
+ fun skill(skill: String) = ItemId("SKYBLOCK_SKILL_$skill")
+
+ val GARDEN = skill("GARDEN")
+ val FARMING = skill("FARMING")
+
+
+ val COINS = ItemId("SKYBLOCK_COIN")
+ val GEMSTONE_POWDER = ItemId("SKYBLOCK_POWDER_GEMSTONE")
+ val MITHRIL_POWDER = ItemId("SKYBLOCK_POWDER_MITHRIL")
+ val NIL = ItemId("SKYBLOCK_NIL")
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ItemIdProvider.kt b/mod/src/main/kotlin/moe/nea/ledger/ItemIdProvider.kt
new file mode 100644
index 0000000..0bacf32
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ItemIdProvider.kt
@@ -0,0 +1,188 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.events.BeforeGuiAction
+import moe.nea.ledger.events.ExtraSupplyIdEvent
+import moe.nea.ledger.events.RegistrationFinishedEvent
+import moe.nea.ledger.events.SupplyDebugInfo
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.modules.ExternalDataProvider
+import net.minecraft.client.Minecraft
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NBTTagCompound
+import net.minecraftforge.client.event.GuiScreenEvent
+import net.minecraftforge.common.MinecraftForge
+import net.minecraftforge.fml.common.eventhandler.EventPriority
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import org.lwjgl.input.Mouse
+
+class ItemIdProvider {
+
+ @SubscribeEvent
+ fun onMouseInput(event: GuiScreenEvent.MouseInputEvent.Pre) {
+ if (Mouse.getEventButton() == -1) return
+ MinecraftForge.EVENT_BUS.post(BeforeGuiAction(event.gui))
+ }
+
+ @SubscribeEvent
+ fun onKeyInput(event: GuiScreenEvent.KeyboardInputEvent.Pre) {
+ MinecraftForge.EVENT_BUS.post(BeforeGuiAction(event.gui))
+ }
+
+ private val knownNames = mutableMapOf<String, ItemId>()
+
+ fun createLookupTagFromDisplayName(itemName: String): String {
+ return itemName.unformattedString().trim().lowercase()
+ }
+
+ fun saveKnownItem(itemName: String, itemId: ItemId) {
+ knownNames[createLookupTagFromDisplayName(itemName)] = itemId
+ }
+
+ @SubscribeEvent
+ fun onDataLoaded(event: ExternalDataProvider.DataLoaded) {
+ event.provider.itemNames.forEach { (itemId, itemName) ->
+ saveKnownItem(itemName, ItemId(itemId))
+ }
+ }
+
+ @SubscribeEvent
+ fun onRegistrationFinished(event: RegistrationFinishedEvent) {
+ MinecraftForge.EVENT_BUS.post(ExtraSupplyIdEvent(::saveKnownItem))
+ }
+
+ @SubscribeEvent(priority = EventPriority.HIGH)
+ fun savePlayerInventoryIds(event: BeforeGuiAction) {
+ val player = Minecraft.getMinecraft().thePlayer ?: return
+ val inventory = player.inventory ?: return
+ inventory.mainInventory?.forEach { saveFromSlot(it) }
+ inventory.armorInventory?.forEach { saveFromSlot(it) }
+ }
+
+ @SubscribeEvent
+ fun onDebugData(event: SupplyDebugInfo) {
+ event.record("knownItemNames", knownNames.size)
+ }
+
+ fun saveFromSlot(stack: ItemStack?, preprocessName: (String) -> String = { it }) {
+ if (stack == null) return
+ val nbt = stack.tagCompound ?: NBTTagCompound()
+ val display = nbt.getCompoundTag("display")
+ var name = display.getString("Name").unformattedString()
+ name = preprocessName(name)
+ name = name.trim()
+ val id = stack.getInternalId()
+ if (id != null && name.isNotBlank()) {
+ saveKnownItem(name, id)
+ }
+ }
+
+ @SubscribeEvent(priority = EventPriority.HIGH)
+ fun saveChestInventoryIds(event: BeforeGuiAction) {
+ val slots = event.chestSlots ?: return
+ val chestName = slots.lowerChestInventory.name.unformattedString()
+ val isOrderMenu = chestName == "Your Bazaar Orders" || chestName == "Co-op Bazaar Orders"
+ val preprocessor: (String) -> String = if (isOrderMenu) {
+ { it.removePrefix("BUY ").removePrefix("SELL ") }
+ } else {
+ { it }
+ }
+ slots.inventorySlots.forEach {
+ saveFromSlot(it?.stack, preprocessor)
+ }
+ }
+
+ // TODO: make use of colour
+ fun findForName(name: String, fallbackToGenerated: Boolean = true): ItemId? {
+ var id = knownNames[createLookupTagFromDisplayName(name)]
+ if (id == null && fallbackToGenerated) {
+ id = generateName(name)
+ }
+ return id
+ }
+
+ fun generateName(name: String): ItemId {
+ return ItemId(name.uppercase().replace(" ", "_"))
+ }
+
+ private val coinRegex = "(?<amount>$SHORT_NUMBER_PATTERN) Coins?".toPattern()
+ private val stackedItemRegex = "(?<name>.*) x(?<count>$SHORT_NUMBER_PATTERN)".toPattern()
+ private val reverseStackedItemRegex = "(?<count>$SHORT_NUMBER_PATTERN)x (?<name>.*)".toPattern()
+ private val essenceRegex = "(?<essence>.*) Essence x(?<count>$SHORT_NUMBER_PATTERN)".toPattern()
+ private val numberedItemRegex = "(?<count>$SHORT_NUMBER_PATTERN) (?<what>.*)".toPattern()
+
+ fun findCostItemsFromSpan(lore: List<String>): List<Pair<ItemId, Double>> {
+ return lore.iterator().asSequence()
+ .dropWhile { it.unformattedString() != "Cost" }.drop(1)
+ .takeWhile { it != "" }
+ .map { findStackableItemByName(it) ?: Pair(ItemId.NIL, 1.0) }
+ .toList()
+ }
+
+ private val etherialRewardPattern = "\\+(?<amount>${SHORT_NUMBER_PATTERN})x? (?<what>.*)".toPattern()
+
+ fun findStackableItemByName(name: String, fallbackToGenerated: Boolean = false): Pair<ItemId, Double>? {
+ val properName = name.unformattedString().trim()
+ if (properName == "FREE" || properName == "This Chest is Free!") {
+ return Pair(ItemId.COINS, 0.0)
+ }
+ coinRegex.useMatcher(properName) {
+ return Pair(ItemId.COINS, parseShortNumber(group("amount")))
+ }
+ etherialRewardPattern.useMatcher(properName) {
+ val id = when (val id = group("what")) {
+ "Copper" -> ItemIds.SKYBLOCK_COPPER
+ "Bits" -> ItemIds.SKYBLOCK_BIT
+ "Garden Experience" -> ItemId.GARDEN
+ "Farming XP" -> ItemId.FARMING
+ "Gold Essence" -> ItemIds.ESSENCE_GOLD
+ "Gemstone Powder" -> ItemId.GEMSTONE_POWDER
+ "Mithril Powder" -> ItemId.MITHRIL_POWDER
+ "Pelts" -> ItemIds.SKYBLOCK_PELT
+ "Fine Flour" -> ItemIds.FINE_FLOUR
+ else -> {
+ id.ifDropLast(" Experience") {
+ ItemId.skill(generateName(it).string)
+ } ?: id.ifDropLast(" XP") {
+ ItemId.skill(generateName(it).string)
+ } ?: id.ifDropLast(" Powder") {
+ ItemId("SKYBLOCK_POWDER_${generateName(it).string}")
+ } ?: id.ifDropLast(" Essence") {
+ ItemId("ESSENCE_${generateName(it).string}")
+ } ?: generateName(id)
+ }
+ }
+ return Pair(id, parseShortNumber(group("amount")))
+ }
+ essenceRegex.useMatcher(properName) {
+ return Pair(ItemId("ESSENCE_${group("essence").uppercase()}"),
+ parseShortNumber(group("count")))
+ }
+ stackedItemRegex.useMatcher(properName) {
+ val item = findForName(group("name"), fallbackToGenerated)
+ if (item != null) {
+ val count = parseShortNumber(group("count"))
+ return Pair(item, count)
+ }
+ }
+ reverseStackedItemRegex.useMatcher(properName) {
+ val item = findForName(group("name"), fallbackToGenerated)
+ if (item != null) {
+ val count = parseShortNumber(group("count"))
+ return Pair(item, count)
+ }
+ }
+ numberedItemRegex.useMatcher(properName) {
+ val item = findForName(group("what"), fallbackToGenerated)
+ if (item != null) {
+ val count = parseShortNumber(group("count"))
+ return Pair(item, count)
+ }
+ }
+
+ return findForName(properName, fallbackToGenerated)?.let { Pair(it, 1.0) }
+ }
+
+ fun getKnownItemIds(): Collection<ItemId> {
+ return knownNames.values
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ItemUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/ItemUtil.kt
new file mode 100644
index 0000000..a3d8162
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ItemUtil.kt
@@ -0,0 +1,90 @@
+package moe.nea.ledger
+
+import net.minecraft.inventory.IInventory
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NBTTagCompound
+
+
+fun ItemStack.getExtraAttributes(): NBTTagCompound {
+ val nbt = this.tagCompound ?: return NBTTagCompound()
+ return nbt.getCompoundTag("ExtraAttributes")
+}
+
+fun ItemStack?.getInternalId(): ItemId? {
+ if (this == null) return null
+ val extraAttributes = getExtraAttributes()
+ var id = extraAttributes.getString("id")
+ id = id.takeIf { it.isNotBlank() }
+ if (id == "PET") {
+ id = getPetId() ?: id
+ }
+ if (id == "ENCHANTED_BOOK") {
+ id = getEnchanments().entries.singleOrNull()?.let {
+ "${it.key};${it.value}".uppercase()
+ }
+ }
+ return id?.let(::ItemId)
+}
+
+fun ItemStack.getEnchanments(): Map<String, Int> {
+ val enchantments = getExtraAttributes().getCompoundTag("enchantments")
+ return enchantments.keySet.associateWith { enchantments.getInteger(it) }
+}
+
+class PetInfo {
+ var type: String? = null
+ var tier: String? = null
+}
+
+fun ItemStack.getPetId(): String? {
+ val petInfoStr = getExtraAttributes().getString("petInfo")
+ val petInfo = runCatching {
+ Ledger.gson.fromJson(petInfoStr,
+ PetInfo::class.java)
+ }.getOrNull() // TODO: error reporting to sentry
+ if (petInfo?.type == null || petInfo.tier == null) return null
+ return petInfo.type + ";" + rarityToIndex(petInfo.tier ?: "")
+}
+
+fun rarityToIndex(rarity: String): Int {
+ return when (rarity) {
+ "COMMON" -> 0
+ "UNCOMMON" -> 1
+ "RARE" -> 2
+ "EPIC" -> 3
+ "LEGENDARY" -> 4
+ "MYTHIC" -> 5
+ else -> -1
+ }
+}
+
+fun ItemStack.getLore(): List<String> {
+ val nbt = this.tagCompound ?: NBTTagCompound()
+ val extraAttributes = nbt.getCompoundTag("display")
+ val lore = extraAttributes.getTagList("Lore", 8)
+ return (0 until lore.tagCount()).map { lore.getStringTagAt(it) }
+}
+
+
+fun IInventory.asIterable(): Iterable<ItemStack?> = object : Iterable<ItemStack?> {
+ override fun iterator(): Iterator<ItemStack?> {
+ return object : Iterator<ItemStack?> {
+ var i = 0
+ override fun hasNext(): Boolean {
+ return i < this@asIterable.sizeInventory
+ }
+
+ override fun next(): ItemStack? {
+ if (!hasNext()) throw NoSuchElementException("$i is out of range for inventory ${this@asIterable}")
+ return this@asIterable.getStackInSlot(i++)
+ }
+ }
+ }
+}
+
+fun ItemStack.getDisplayNameU(): String {
+ val nbt = this.tagCompound ?: NBTTagCompound()
+ val extraAttributes = nbt.getCompoundTag("display")
+ return extraAttributes.getString("Name")
+}
+
diff --git a/mod/src/main/kotlin/moe/nea/ledger/Ledger.kt b/mod/src/main/kotlin/moe/nea/ledger/Ledger.kt
new file mode 100644
index 0000000..bc667e4
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/Ledger.kt
@@ -0,0 +1,205 @@
+package moe.nea.ledger
+
+import com.google.gson.Gson
+import io.github.notenoughupdates.moulconfig.Config
+import io.github.notenoughupdates.moulconfig.managed.ManagedConfig
+import moe.nea.ledger.config.LedgerConfig
+import moe.nea.ledger.config.UpdateUi
+import moe.nea.ledger.config.UpdateUiMarker
+import moe.nea.ledger.database.Database
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.LateWorldLoadEvent
+import moe.nea.ledger.events.RegistrationFinishedEvent
+import moe.nea.ledger.events.WorldSwitchEvent
+import moe.nea.ledger.gen.BuildConfig
+import moe.nea.ledger.modules.AccessorySwapperDetection
+import moe.nea.ledger.modules.AllowanceDetection
+import moe.nea.ledger.modules.AuctionHouseDetection
+import moe.nea.ledger.modules.BankDetection
+import moe.nea.ledger.modules.BazaarDetection
+import moe.nea.ledger.modules.BazaarOrderDetection
+import moe.nea.ledger.modules.BitsDetection
+import moe.nea.ledger.modules.BitsShopDetection
+import moe.nea.ledger.modules.DragonEyePlacementDetection
+import moe.nea.ledger.modules.`DragonSacrificeDetection`
+import moe.nea.ledger.modules.DungeonChestDetection
+import moe.nea.ledger.modules.ExternalDataProvider
+import moe.nea.ledger.modules.EyedropsDetection
+import moe.nea.ledger.modules.ForgeDetection
+import moe.nea.ledger.modules.GambleDetection
+import moe.nea.ledger.modules.GodPotionDetection
+import moe.nea.ledger.modules.GodPotionMixinDetection
+import moe.nea.ledger.modules.KatDetection
+import moe.nea.ledger.modules.KuudraChestDetection
+import moe.nea.ledger.modules.MineshaftCorpseDetection
+import moe.nea.ledger.modules.MinionDetection
+import moe.nea.ledger.modules.NpcDetection
+import moe.nea.ledger.modules.UpdateChecker
+import moe.nea.ledger.modules.VisitorDetection
+import moe.nea.ledger.utils.ErrorUtil
+import moe.nea.ledger.utils.MinecraftExecutor
+import moe.nea.ledger.utils.di.DI
+import moe.nea.ledger.utils.di.DIProvider
+import moe.nea.ledger.utils.network.RequestUtil
+import net.minecraft.client.Minecraft
+import net.minecraft.command.ICommand
+import net.minecraftforge.client.ClientCommandHandler
+import net.minecraftforge.client.event.ClientChatReceivedEvent
+import net.minecraftforge.common.MinecraftForge
+import net.minecraftforge.event.entity.EntityJoinWorldEvent
+import net.minecraftforge.fml.common.Mod
+import net.minecraftforge.fml.common.event.FMLInitializationEvent
+import net.minecraftforge.fml.common.eventhandler.EventPriority
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import net.minecraftforge.fml.common.gameevent.TickEvent
+import net.minecraftforge.fml.common.gameevent.TickEvent.ClientTickEvent
+import org.apache.logging.log4j.LogManager
+import java.io.File
+import java.util.concurrent.ConcurrentLinkedQueue
+
+@Mod(modid = "ledger", useMetadata = true, version = BuildConfig.VERSION)
+class Ledger {
+ /*
+ You have withdrawn 1M coins! You now have 518M coins in your account!
+ You have deposited 519M coins! You now have 519M coins in your account!
+
+ // ORDERS:
+
+ [Bazaar] Buy Order Setup! 160x Wheat for 720.0 coins.
+ [Bazaar] Claimed 160x Wheat worth 720.0 coins bought for 4.5 each!
+
+ [Bazaar] Sell Offer Setup! 160x Wheat for 933.4 coins.
+ [Bazaar] Claimed 34,236,799 coins from selling 176x Hyper Catalyst at 196,741 each!
+
+ // INSTABUY:
+
+ [Bazaar] Bought 64x Wheat for 377.6 coins!
+ [Bazaar] Sold 64x Wheat for 268.8 coins!
+
+ // AUCTION HOUSE:
+
+ You collected 8,712,000 coins from selling Ultimate Carrot Candy Upgrade to [VIP] kodokush in an auction!
+ You purchased 2x Walnut for 69 coins!
+ You purchased ◆ Ice Rune I for 4,000 coins!
+
+ // NPC
+
+ // You bought Cactus x32 for 465.6 Coins!
+ // You sold Cactus x1 for 3 Coins!
+ // You bought back Potato x3 for 9 Coins!
+
+ TODO: TRADING, FORGE, VISITORS / COPPER, CORPSES ÖFFNEN, HIGH / LOW GAMBLES, MINION ITEMS (maybe inferno refuel)
+ TODO: PET LEVELING COSTS AT FANN, SLAYER / MOB DROPS, SLAYER START COST
+ */
+ companion object {
+ val dataFolder = File("money-ledger").apply { mkdirs() }
+ val logger = LogManager.getLogger("MoneyLedger")
+ val managedConfig = ManagedConfig.create(File("config/money-ledger/config.json"), LedgerConfig::class.java) {
+ checkExpose = false
+ customProcessor<UpdateUiMarker> { option, ann ->
+ UpdateUi(option)
+ }
+ }
+ val gson = Gson()
+ private val tickQueue = ConcurrentLinkedQueue<Runnable>()
+ fun runLater(runnable: Runnable) {
+ tickQueue.add(runnable)
+ }
+
+ val di = DI()
+ }
+
+ @Mod.EventHandler
+ fun init(event: FMLInitializationEvent) {
+ logger.info("Initializing ledger")
+
+ TelemetryProvider.setupFor(di)
+ di.registerSingleton(this)
+ di.registerSingleton(Minecraft.getMinecraft())
+ di.registerSingleton(gson)
+ di.register(LedgerConfig::class.java, DIProvider { managedConfig.instance })
+ di.register(Config::class.java, DIProvider.fromInheritance(LedgerConfig::class.java))
+ di.registerInjectableClasses(
+ AccessorySwapperDetection::class.java,
+ AllowanceDetection::class.java,
+ AuctionHouseDetection::class.java,
+ BankDetection::class.java,
+ BazaarDetection::class.java,
+ BazaarOrderDetection::class.java,
+ BitsDetection::class.java,
+ BitsShopDetection::class.java,
+ ConfigCommand::class.java,
+ Database::class.java,
+ DebugDataCommand::class.java,
+ DragonEyePlacementDetection::class.java,
+ DragonSacrificeDetection::class.java,
+ DungeonChestDetection::class.java,
+ ErrorUtil::class.java,
+ ExternalDataProvider::class.java,
+ EyedropsDetection::class.java,
+ ForgeDetection::class.java,
+ GambleDetection::class.java,
+ GodPotionDetection::class.java,
+ GodPotionMixinDetection::class.java,
+ ItemIdProvider::class.java,
+ KatDetection::class.java,
+ KuudraChestDetection::class.java,
+ LedgerLogger::class.java,
+ LogChatCommand::class.java,
+ MinecraftExecutor::class.java,
+ MineshaftCorpseDetection::class.java,
+ MinionDetection::class.java,
+ NpcDetection::class.java,
+ QueryCommand::class.java,
+ RequestUtil::class.java,
+ TriggerCommand::class.java,
+ UpdateChecker::class.java,
+ VisitorDetection::class.java,
+ )
+ val errorUtil = di.provide<ErrorUtil>()
+ errorUtil.catch {
+ di.instantiateAll()
+ di.getAllInstances().forEach(MinecraftForge.EVENT_BUS::register)
+ di.getAllInstances().filterIsInstance<ICommand>()
+ .forEach { ClientCommandHandler.instance.registerCommand(it) }
+ }
+
+ errorUtil.catch {
+ di.provide<Database>().loadAndUpgrade()
+ }
+
+ MinecraftForge.EVENT_BUS.post(RegistrationFinishedEvent())
+ }
+
+ var lastJoin = -1L
+
+ @SubscribeEvent
+ fun worldSwitchEvent(event: EntityJoinWorldEvent) {
+ if (event.entity == Minecraft.getMinecraft().thePlayer) {
+ lastJoin = System.currentTimeMillis()
+ MinecraftForge.EVENT_BUS.post(WorldSwitchEvent())
+ }
+ }
+
+ @SubscribeEvent
+ fun tickEvent(event: ClientTickEvent) {
+ if (event.phase == TickEvent.Phase.END
+ && lastJoin > 0
+ && System.currentTimeMillis() - lastJoin > 10_000
+ && Minecraft.getMinecraft().thePlayer != null
+ ) {
+ lastJoin = -1
+ MinecraftForge.EVENT_BUS.post(LateWorldLoadEvent())
+ }
+ while (true) {
+ val queued = tickQueue.poll() ?: break
+ queued.run()
+ }
+ }
+
+ @SubscribeEvent(receiveCanceled = true, priority = EventPriority.HIGHEST)
+ fun onChat(event: ClientChatReceivedEvent) {
+ if (event.type != 2.toByte())
+ MinecraftForge.EVENT_BUS.post(ChatReceived(event))
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/LedgerEntry.kt b/mod/src/main/kotlin/moe/nea/ledger/LedgerEntry.kt
new file mode 100644
index 0000000..d4a3932
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/LedgerEntry.kt
@@ -0,0 +1,29 @@
+package moe.nea.ledger
+
+import com.google.gson.JsonObject
+import moe.nea.ledger.gen.ItemIds
+import java.time.Instant
+import java.util.UUID
+
+data class LedgerEntry(
+ val transactionType: TransactionType,
+ val timestamp: Instant,
+ val items: List<ItemChange>,
+) {
+ fun intoJson(profileId: UUID?): JsonObject {
+ val coinAmount = items.find { it.itemId == ItemId.COINS || it.itemId == ItemIds.SKYBLOCK_BIT }?.count
+ val nonCoins = items.find { it.itemId != ItemId.COINS && it.itemId != ItemIds.SKYBLOCK_BIT }
+ return JsonObject().apply {
+ addProperty("transactionType", transactionType.name)
+ addProperty("timestamp", timestamp.toEpochMilli().toString())
+ addProperty("totalTransactionValue", coinAmount)
+ addProperty("itemId", nonCoins?.itemId?.string ?: "")
+ addProperty("itemAmount", nonCoins?.count ?: 0.0)
+ addProperty("profileId", profileId.toString())
+ addProperty(
+ "playerId",
+ MCUUIDUtil.getPlayerUUID().toString()
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/LedgerLogger.kt b/mod/src/main/kotlin/moe/nea/ledger/LedgerLogger.kt
new file mode 100644
index 0000000..6049aa2
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/LedgerLogger.kt
@@ -0,0 +1,136 @@
+package moe.nea.ledger
+
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import moe.nea.ledger.database.DBItemEntry
+import moe.nea.ledger.database.DBLogEntry
+import moe.nea.ledger.database.Database
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.utils.ULIDWrapper
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.client.Minecraft
+import net.minecraft.util.ChatComponentText
+import net.minecraft.util.IChatComponent
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import net.minecraftforge.fml.common.gameevent.TickEvent.ClientTickEvent
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.UUID
+
+class LedgerLogger {
+ fun printOut(text: String) = printOut(ChatComponentText(text))
+ fun printOut(comp: IChatComponent) {
+ Minecraft.getMinecraft().ingameGUI?.chatGUI?.printChatMessage(comp)
+ }
+
+ val profileIdPattern =
+ "Profile ID: (?<profile>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})".toPattern()
+
+ var currentProfile: UUID? = null
+
+ var shouldLog by Ledger.managedConfig.instance.debug::logEntries
+
+ @Inject
+ lateinit var database: Database
+
+ @SubscribeEvent
+ fun onProfileSwitch(event: ChatReceived) {
+ profileIdPattern.useMatcher(event.message) {
+ currentProfile = UUID.fromString(group("profile"))
+ }
+ }
+
+
+ fun printToChat(entry: LedgerEntry) {
+ val items = entry.items.joinToString("\n§e") { " - ${it.direction} ${it.count}x ${it.itemId}" }
+ printOut(
+ """
+ §e================= TRANSACTION START
+ §eTYPE: §a${entry.transactionType}
+ §eTIMESTAMP: §a${entry.timestamp}
+ §e%s
+ §ePROFILE: §a${currentProfile}
+ §e================= TRANSACTION END
+ """.trimIndent().replace("%s", items)
+ )
+ }
+
+ val entries = JsonArray()
+ var hasRecentlyMerged = false
+ var lastMergeTime = System.currentTimeMillis()
+
+ fun doMerge() {
+ val allFiles = folder.listFiles()?.toList() ?: emptyList()
+ val mergedJson = allFiles
+ .filter { it.name != "merged.json" && it.extension == "json" }
+ .sortedDescending()
+ .map { it.readText().trim().removePrefix("[").removeSuffix("]") }
+ .joinToString(",", "[", "]")
+ folder.resolve("merged.json").writeText(mergedJson)
+ hasRecentlyMerged = true
+ }
+
+ init {
+ Runtime.getRuntime().addShutdownHook(Thread { doMerge() })
+ }
+
+ @SubscribeEvent
+ fun onTick(event: ClientTickEvent) {
+ if (!hasRecentlyMerged && (System.currentTimeMillis() - lastMergeTime) > 60_000L) {
+ lastMergeTime = System.currentTimeMillis()
+ doMerge()
+ }
+ }
+
+ fun logEntry(entry: LedgerEntry) {
+ if (shouldLog)
+ printToChat(entry)
+ Ledger.logger.info("Logging entry of type ${entry.transactionType}")
+ val transactionId = ULIDWrapper.createULIDAt(entry.timestamp)
+ DBLogEntry.insert(database.connection) {
+ it[DBLogEntry.profileId] = currentProfile ?: MCUUIDUtil.NIL_UUID
+ it[DBLogEntry.playerId] = MCUUIDUtil.getPlayerUUID()
+ it[DBLogEntry.type] = entry.transactionType
+ it[DBLogEntry.transactionId] = transactionId
+ }
+ entry.items.forEach { change ->
+ DBItemEntry.insert(database.connection) {
+ it[DBItemEntry.transactionId] = transactionId
+ it[DBItemEntry.mode] = change.direction
+ it[DBItemEntry.size] = change.count
+ it[DBItemEntry.itemId] = change.itemId
+ }
+ }
+ entries.add(entry.intoJson(currentProfile))
+ commit()
+ }
+
+ fun commit() {
+ try {
+ hasRecentlyMerged = false
+ file.writeText(gson.toJson(entries))
+ } catch (ex: Exception) {
+ Ledger.logger.error("Could not save file", ex)
+ }
+ }
+
+ val gson = Gson()
+
+ val folder = Ledger.dataFolder
+ val file: File = run {
+ val date = SimpleDateFormat("yyyy.MM.dd").format(Date())
+
+ generateSequence(0) { it + 1 }
+ .map {
+ if (it == 0)
+ folder.resolve("$date.json")
+ else
+ folder.resolve("$date-$it.json")
+ }
+ .filter { !it.exists() }
+ .first()
+ }
+}
+
+
diff --git a/mod/src/main/kotlin/moe/nea/ledger/LogChatCommand.kt b/mod/src/main/kotlin/moe/nea/ledger/LogChatCommand.kt
new file mode 100644
index 0000000..90b2545
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/LogChatCommand.kt
@@ -0,0 +1,27 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.command.CommandBase
+import net.minecraft.command.ICommandSender
+
+class LogChatCommand : CommandBase() {
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ override fun getCommandName(): String {
+ return "ledgerlogchat"
+ }
+
+ override fun canCommandSenderUseCommand(sender: ICommandSender?): Boolean {
+ return true
+ }
+
+ override fun getCommandUsage(sender: ICommandSender?): String {
+ return ""
+ }
+
+ override fun processCommand(sender: ICommandSender?, args: Array<out String>?) {
+ logger.shouldLog = !logger.shouldLog
+ logger.printOut("§eLedger logging toggled " + (if (logger.shouldLog) "§aon" else "§coff") + "§e.")
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/MCUUIDUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/MCUUIDUtil.kt
new file mode 100644
index 0000000..79068cc
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/MCUUIDUtil.kt
@@ -0,0 +1,22 @@
+package moe.nea.ledger
+
+import com.mojang.util.UUIDTypeAdapter
+import net.minecraft.client.Minecraft
+import java.util.UUID
+
+object MCUUIDUtil {
+
+ fun parseDashlessUuid(string: String) = UUIDTypeAdapter.fromString(string)
+ val NIL_UUID = UUID(0L, 0L)
+ fun getPlayerUUID(): UUID {
+ val currentUUID = Minecraft.getMinecraft().thePlayer?.uniqueID
+ ?: Minecraft.getMinecraft().session?.playerID?.let(::parseDashlessUuid)
+ ?: lastKnownUUID
+ lastKnownUUID = currentUUID
+ return currentUUID
+ }
+
+
+ private var lastKnownUUID: UUID = NIL_UUID
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/NumberUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/NumberUtil.kt
new file mode 100644
index 0000000..438f342
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/NumberUtil.kt
@@ -0,0 +1,117 @@
+package moe.nea.ledger
+
+import net.minecraft.event.ClickEvent
+import net.minecraft.event.HoverEvent
+import net.minecraft.util.ChatComponentText
+import net.minecraft.util.ChatStyle
+import net.minecraft.util.EnumChatFormatting
+import net.minecraft.util.IChatComponent
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.temporal.ChronoField
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+// language=regexp
+val SHORT_NUMBER_PATTERN = "[0-9]+(?:,[0-9]+)*(?:\\.[0-9]+)?[kKmMbB]?"
+
+// language=regexp
+val ROMAN_NUMBER_PATTERN = "[IVXLCDM]+"
+
+val romanNumbers = mapOf(
+ 'I' to 1,
+ 'V' to 5,
+ 'X' to 10,
+ 'L' to 50,
+ 'C' to 100,
+ 'D' to 500,
+ 'M' to 1000
+)
+
+fun parseRomanNumber(string: String): Int {
+ var smallestSeenSoFar = Int.MAX_VALUE
+ var lastSeenOfSmallest = 0
+ var amount = 0
+ for (c in string) {
+ val cV = romanNumbers[c]!!
+ if (cV == smallestSeenSoFar) {
+ lastSeenOfSmallest++
+ amount += cV
+ } else if (cV < smallestSeenSoFar) {
+ smallestSeenSoFar = cV
+ amount += cV
+ lastSeenOfSmallest = 1
+ } else {
+ amount -= lastSeenOfSmallest * smallestSeenSoFar * 2
+ smallestSeenSoFar = cV
+ amount += cV
+ lastSeenOfSmallest = 1
+ }
+ }
+ return amount
+}
+
+val siScalars = mapOf(
+ 'k' to 1_000.0,
+ 'K' to 1_000.0,
+ 'm' to 1_000_000.0,
+ 'M' to 1_000_000.0,
+ 'b' to 1_000_000_000.0,
+ 'B' to 1_000_000_000.0,
+)
+
+fun parseShortNumber(string: String): Double {
+ var k = string.replace(",", "")
+ val scalar = k.last()
+ var scalarMultiplier = siScalars[scalar]
+ if (scalarMultiplier == null) {
+ scalarMultiplier = 1.0
+ } else {
+ k = k.dropLast(1)
+ }
+ return k.toDouble() * scalarMultiplier
+}
+
+fun Pattern.matches(string: String): Boolean = matcher(string).matches()
+inline fun <T> Pattern.useMatcher(string: String, block: Matcher.() -> T): T? =
+ matcher(string).takeIf { it.matches() }?.let(block)
+
+fun <T> String.ifDropLast(suffix: String, block: (String) -> T): T? {
+ if (endsWith(suffix)) {
+ return block(dropLast(suffix.length))
+ }
+ return null
+}
+
+fun String.unformattedString(): String = replace("§.".toRegex(), "")
+
+val timeFormat: DateTimeFormatter = DateTimeFormatterBuilder()
+ .appendValue(ChronoField.DAY_OF_MONTH, 2)
+ .appendLiteral(".")
+ .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+ .appendLiteral(".")
+ .appendValue(ChronoField.YEAR, 4)
+ .appendLiteral(" ")
+ .appendValue(ChronoField.HOUR_OF_DAY, 2)
+ .appendLiteral(":")
+ .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+ .appendLiteral(":")
+ .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+ .toFormatter()
+
+fun Instant.formatChat(): IChatComponent {
+ val text = ChatComponentText(
+ LocalDateTime.ofInstant(this, ZoneId.systemDefault()).format(timeFormat)
+ )
+ text.setChatStyle(
+ ChatStyle()
+ .setChatClickEvent(
+ ClickEvent(ClickEvent.Action.OPEN_URL, "https://time.is/${this.epochSecond}"))
+ .setChatHoverEvent(
+ HoverEvent(HoverEvent.Action.SHOW_TEXT, ChatComponentText("Click to show on time.is")))
+ .setColor(EnumChatFormatting.AQUA))
+ return text
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/QueryCommand.kt b/mod/src/main/kotlin/moe/nea/ledger/QueryCommand.kt
new file mode 100644
index 0000000..19bd5d0
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/QueryCommand.kt
@@ -0,0 +1,197 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.database.sql.ANDExpression
+import moe.nea.ledger.database.sql.BooleanExpression
+import moe.nea.ledger.database.sql.Clause
+import moe.nea.ledger.database.DBItemEntry
+import moe.nea.ledger.database.DBLogEntry
+import moe.nea.ledger.database.Database
+import moe.nea.ledger.utils.ULIDWrapper
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.command.CommandBase
+import net.minecraft.command.ICommandSender
+import net.minecraft.util.BlockPos
+import net.minecraft.util.ChatComponentText
+import net.minecraft.util.ChatStyle
+import net.minecraft.util.EnumChatFormatting
+
+class QueryCommand : CommandBase() {
+ override fun canCommandSenderUseCommand(sender: ICommandSender?): Boolean {
+ return true
+ }
+
+ override fun getCommandName(): String {
+ return "ledger"
+ }
+
+ override fun getCommandUsage(sender: ICommandSender?): String {
+ return ""
+ }
+
+ override fun getCommandAliases(): List<String> {
+ return listOf("lgq")
+ }
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ override fun processCommand(sender: ICommandSender, args: Array<out String>) {
+ if (args.isEmpty()) {
+ logger.printOut("§eHere is how you can look up transactions:")
+ logger.printOut("")
+ logger.printOut("§f- §e/ledger withitem %POTATO%")
+ logger.printOut(" §aLook up transactions involving potatoes!")
+ logger.printOut("§f- §e/ledger withitem ENCHANTED_POTATO")
+ logger.printOut(" §aLook up transactions involving just enchanted potatoes!")
+ logger.printOut("§f- §e/ledger withitem %POTATO% withitem %CARROT%")
+ logger.printOut(" §aLook up transactions involving potatoes or carrots!")
+ logger.printOut("§f- §e/ledger withtype AUCTION_SOLD")
+ logger.printOut(" §aLook up transactions of sold auctions!")
+ logger.printOut("§f- §e/ledger withtype AUCTION_SOLD withitem CRIMSON%")
+ logger.printOut(" §aLook up sold auctions involving crimson armor pieces!")
+ logger.printOut("")
+ logger.printOut("§eFilters of the same type apply using §aOR§e and loggers of different types apply using §aAND§e.")
+ logger.printOut("§eYou can use % as a wildcard!")
+ return
+ }
+ val p = parseArgs(args)
+ when (p) {
+ is ParseResult.Success -> {
+ executeQuery(p)
+ }
+
+ is ParseResult.UnknownFilter -> {
+ logger.printOut("§cUnknown filter name ${p.start}. Available filter names are: ${mFilters.keys.joinToString()}")
+ }
+
+ is ParseResult.MissingArg -> {
+ logger.printOut("§cFilter ${p.filterM.name} is missing an argument.")
+ }
+ }
+ }
+
+ override fun addTabCompletionOptions(
+ sender: ICommandSender,
+ args: Array<out String>,
+ pos: BlockPos
+ ): MutableList<String>? {
+ when (val p = parseArgs(args)) {
+ is ParseResult.MissingArg -> return null
+ is ParseResult.Success -> return p.lastFilterM.tabComplete(args.last())
+ is ParseResult.UnknownFilter -> return getListOfStringsMatchingLastWord(args, mFilters.keys)
+ }
+ }
+
+ @Inject
+ lateinit var database: Database
+ private fun executeQuery(parse: ParseResult.Success) {
+ val grouped = parse.filters
+ val query = DBLogEntry.from(database.connection)
+ .select(DBLogEntry.type, DBLogEntry.transactionId)
+ .join(DBItemEntry, on = Clause { column(DBLogEntry.transactionId) eq column(DBItemEntry.transactionId) })
+ for (value in grouped.values) {
+ query.where(ANDExpression(value))
+ }
+ query.limit(80u)
+ val dedup = mutableSetOf<ULIDWrapper>()
+ query.forEach {
+ val type = it[DBLogEntry.type]
+ val transactionId = it[DBLogEntry.transactionId]
+ if (!dedup.add(transactionId)) {
+ return@forEach
+ }
+ val timestamp = transactionId.getTimestamp()
+ val items = DBItemEntry.selectAll(database.connection)
+ .where(Clause { column(DBItemEntry.transactionId) eq string(transactionId.wrapped) })
+ .map { ItemChange.from(it) }
+ val text = ChatComponentText("")
+ .setChatStyle(ChatStyle().setColor(EnumChatFormatting.YELLOW))
+ .appendSibling(
+ ChatComponentText(type.name)
+ .setChatStyle(ChatStyle().setColor(EnumChatFormatting.GREEN))
+ )
+ .appendText(" on ")
+ .appendSibling(timestamp.formatChat())
+ .appendText("\n")
+ .appendSibling(
+ ChatComponentText(transactionId.wrapped).setChatStyle(ChatStyle().setColor(EnumChatFormatting.DARK_GRAY))
+ )
+ for (item in items) {
+ text.appendText("\n")
+ .appendSibling(item.formatChat())
+ }
+ text.appendText("\n")
+ logger.printOut(text)
+ }
+ }
+
+ sealed interface ParseResult {
+ data class UnknownFilter(val start: String) : ParseResult
+ data class MissingArg(val filterM: FilterM) : ParseResult
+ data class Success(val lastFilterM: FilterM, val filters: Map<FilterM, List<BooleanExpression>>) : ParseResult
+ }
+
+ fun parseArgs(args: Array<out String>): ParseResult {
+ require(args.isNotEmpty())
+ val arr = args.iterator()
+ val filters = mutableMapOf<FilterM, MutableList<BooleanExpression>>()
+ var lastFilterM: FilterM? = null
+ while (arr.hasNext()) {
+ val filterName = arr.next()
+ val filterM = mFilters[filterName]
+ if (filterM == null) {
+ return ParseResult.UnknownFilter(filterName)
+ }
+ if (!arr.hasNext()) {
+ return ParseResult.MissingArg(filterM)
+ }
+ filters.getOrPut(filterM, ::mutableListOf).add(filterM.getFilter(arr.next()))
+ lastFilterM = filterM
+ }
+ return ParseResult.Success(lastFilterM!!, filters)
+ }
+
+
+ val mFilters = listOf(TypeFilter, ItemFilter).associateBy { it.name }
+
+ object TypeFilter : FilterM {
+ override val name: String
+ get() = "withtype"
+
+ override fun getFilter(text: String): BooleanExpression {
+ val preparedText = "%" + text.trim('%') + "%"
+ return Clause { column(DBLogEntry.type) like preparedText }
+ }
+
+ override fun tabComplete(partialArg: String): MutableList<String> {
+ return TransactionType.entries.asSequence().map { it.name }.filter { partialArg in it }.toMutableList()
+ }
+ }
+
+ object ItemFilter : FilterM {
+ override val name: String
+ get() = "withitem"
+
+ private val itemIdProvider = Ledger.di.provide<ItemIdProvider>() // TODO: close this escape hatch
+ override fun getFilter(text: String): BooleanExpression {
+ return Clause { column(DBItemEntry.itemId) like text }
+ }
+
+ override fun tabComplete(partialArg: String): MutableList<String>? {
+ return itemIdProvider.getKnownItemIds()
+ .asSequence()
+ .map { it.string }
+ .filter { partialArg in it }
+ .take(100)
+ .toMutableList()
+ }
+ }
+
+ interface FilterM {
+ val name: String
+ fun getFilter(text: String): BooleanExpression
+ fun tabComplete(partialArg: String): MutableList<String>?
+// fun tabCompleteFilter() TODO
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/ScoreboardUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/ScoreboardUtil.kt
new file mode 100644
index 0000000..783664b
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/ScoreboardUtil.kt
@@ -0,0 +1,29 @@
+package moe.nea.ledger
+
+import net.minecraft.client.Minecraft
+import net.minecraft.scoreboard.ScorePlayerTeam
+
+object ScoreboardUtil {
+
+ val sidebarSlot = 1
+ fun getScoreboardStrings(): List<String> {
+ val scoreboard = Minecraft.getMinecraft().theWorld.scoreboard
+ val objective = scoreboard.getObjectiveInDisplaySlot(sidebarSlot)
+ val scoreList = scoreboard.getSortedScores(objective).take(15)
+ .map {
+ ScorePlayerTeam.formatPlayerName(scoreboard.getPlayersTeam(it.playerName), it.playerName)
+ }
+ .map { stripAlien(it) }
+ .reversed()
+ return scoreList
+ }
+
+ fun stripAlien(string: String): String {
+ val sb = StringBuilder()
+ for (c in string) {
+ if (Minecraft.getMinecraft().fontRendererObj.getCharWidth(c) > 0 || c == '§')
+ sb.append(c)
+ }
+ return sb.toString()
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/TelemetryProvider.kt b/mod/src/main/kotlin/moe/nea/ledger/TelemetryProvider.kt
new file mode 100644
index 0000000..d9c7108
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/TelemetryProvider.kt
@@ -0,0 +1,66 @@
+package moe.nea.ledger
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import moe.nea.ledger.gen.BuildConfig
+import moe.nea.ledger.utils.di.DI
+import moe.nea.ledger.utils.di.DIProvider
+import moe.nea.ledger.utils.telemetry.CommonKeys
+import moe.nea.ledger.utils.telemetry.ContextValue
+import moe.nea.ledger.utils.telemetry.EventRecorder
+import moe.nea.ledger.utils.telemetry.JsonElementContext
+import moe.nea.ledger.utils.telemetry.LoggingEventRecorder
+import moe.nea.ledger.utils.telemetry.Span
+import net.minecraft.client.Minecraft
+import net.minecraft.util.Session
+import net.minecraftforge.fml.common.Loader
+
+object TelemetryProvider {
+ fun injectTo(di: DI) {
+ di.register(
+ EventRecorder::class.java,
+ if (DevUtil.isDevEnv) DIProvider.singeleton(LoggingEventRecorder(Ledger.logger, true))
+ else DIProvider.singeleton(
+ LoggingEventRecorder(Ledger.logger, false)) // TODO: replace with upload to server
+ )
+ }
+
+ val USER = "minecraft_user"
+ val MINECRAFT_VERSION = "minecraft_version"
+ val MODS = "mods"
+
+ class MinecraftUser(val session: Session) : ContextValue {
+ override fun serialize(): JsonElement {
+ val obj = JsonObject()
+ obj.addProperty("uuid", session.playerID)
+ obj.addProperty("name", session.username)
+ return obj
+ }
+ }
+
+ fun setupDefaultSpan() {
+ val sp = Span.current()
+ sp.add(USER, MinecraftUser(Minecraft.getMinecraft().session))
+ sp.add(MINECRAFT_VERSION, ContextValue.compound(
+ "static" to "1.8.9",
+ "rt" to Minecraft.getMinecraft().version,
+ ))
+ val mods = JsonArray()
+ Loader.instance().activeModList.map {
+ val obj = JsonObject()
+ obj.addProperty("id", it.modId)
+ obj.addProperty("version", it.version)
+ obj.addProperty("displayVersion", it.displayVersion)
+ obj
+ }.forEach(mods::add)
+ sp.add(MODS, JsonElementContext(mods))
+ sp.add(CommonKeys.VERSION, ContextValue.string(BuildConfig.FULL_VERSION))
+ sp.add(CommonKeys.COMMIT_VERSION, ContextValue.string(BuildConfig.GIT_COMMIT))
+ }
+
+ fun setupFor(di: DI) {
+ injectTo(di)
+ setupDefaultSpan()
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/TransactionType.kt b/mod/src/main/kotlin/moe/nea/ledger/TransactionType.kt
new file mode 100644
index 0000000..33c633d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/TransactionType.kt
@@ -0,0 +1,35 @@
+package moe.nea.ledger
+
+enum class TransactionType {
+ ACCESSORIES_SWAPPING,
+ ALLOWANCE_GAIN,
+ AUCTION_BOUGHT,
+ AUCTION_LISTING_CHARGE,
+ AUCTION_SOLD,
+ AUTOMERCHANT_PROFIT_COLLECT,
+ BANK_DEPOSIT,
+ BANK_WITHDRAW,
+ BAZAAR_BUY_INSTANT,
+ BAZAAR_BUY_ORDER,
+ BAZAAR_SELL_INSTANT,
+ BAZAAR_SELL_ORDER,
+ BITS_PURSE_STATUS,
+ BOOSTER_COOKIE_ATE,
+ CAPSAICIN_EYEDROPS_USED,
+ COMMUNITY_SHOP_BUY,
+ CORPSE_DESECRATED,
+ DIE_ROLLED,
+ DRACONIC_SACRIFICE,
+ DUNGEON_CHEST_OPEN,
+ FORGED,
+ GOD_POTION_DRANK,
+ GOD_POTION_MIXIN_DRANK,
+ KAT_TIMESKIP,
+ KAT_UPGRADE,
+ KISMET_REROLL,
+ KUUDRA_CHEST_OPEN,
+ NPC_BUY,
+ NPC_SELL,
+ VISITOR_BARGAIN,
+ WYRM_EVOKED,
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/TriggerCommand.kt b/mod/src/main/kotlin/moe/nea/ledger/TriggerCommand.kt
new file mode 100644
index 0000000..c97627d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/TriggerCommand.kt
@@ -0,0 +1,34 @@
+package moe.nea.ledger
+
+import moe.nea.ledger.events.TriggerEvent
+import net.minecraft.command.CommandBase
+import net.minecraft.command.ICommandSender
+import net.minecraft.event.ClickEvent
+import net.minecraft.util.ChatComponentText
+import net.minecraftforge.common.MinecraftForge
+
+class TriggerCommand : CommandBase() {
+ fun getTriggerCommandLine(trigger: String): ClickEvent {
+ return ClickEvent(ClickEvent.Action.RUN_COMMAND, "/${commandName} $trigger")
+ }
+
+ override fun getCommandName(): String {
+ return "__ledgertrigger"
+ }
+
+ override fun getCommandUsage(sender: ICommandSender?): String {
+ return ""
+ }
+
+ override fun processCommand(sender: ICommandSender, args: Array<out String>) {
+ val event = TriggerEvent(args.joinToString(" "))
+ MinecraftForge.EVENT_BUS.post(event)
+ if (!event.isCanceled)
+ sender.addChatMessage(ChatComponentText("§cCould not find the given trigger. This is an internal command for ledger."))
+ }
+
+ override fun canCommandSenderUseCommand(sender: ICommandSender?): Boolean {
+ return true
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/DebugOptions.kt b/mod/src/main/kotlin/moe/nea/ledger/config/DebugOptions.kt
new file mode 100644
index 0000000..fd5ed3d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/DebugOptions.kt
@@ -0,0 +1,13 @@
+package moe.nea.ledger.config
+
+import io.github.notenoughupdates.moulconfig.annotations.ConfigEditorBoolean
+import io.github.notenoughupdates.moulconfig.annotations.ConfigOption
+
+class DebugOptions {
+ @ConfigOption(name = "Log entries to chat",
+ desc = "Appends all logged entries into the chat as they are logged. This option does not persist on restarts.")
+ @Transient
+ @ConfigEditorBoolean
+ @JvmField
+ var logEntries = false
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/LedgerConfig.kt b/mod/src/main/kotlin/moe/nea/ledger/config/LedgerConfig.kt
new file mode 100644
index 0000000..91ee5c1
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/LedgerConfig.kt
@@ -0,0 +1,35 @@
+package moe.nea.ledger.config
+
+import io.github.notenoughupdates.moulconfig.Config
+import io.github.notenoughupdates.moulconfig.DescriptionRendereringBehaviour
+import io.github.notenoughupdates.moulconfig.annotations.Category
+import io.github.notenoughupdates.moulconfig.processor.ProcessedOption
+import moe.nea.ledger.Ledger
+
+class LedgerConfig : Config() {
+ override fun getTitle(): String {
+ return "§6Ledger §7- §6Hypixel SkyBlock data logger §7by §anea89o"
+ }
+
+ override fun saveNow() {
+ super.saveNow()
+ Ledger.managedConfig.saveToFile()
+ }
+
+ override fun getDescriptionBehaviour(option: ProcessedOption?): DescriptionRendereringBehaviour {
+ return DescriptionRendereringBehaviour.EXPAND_PANEL
+ }
+
+ @Category(name = "Ledger", desc = "")
+ @JvmField
+ val main = MainOptions()
+
+ @Category(name = "Synchronization", desc = "")
+ @JvmField
+ val synchronization = SynchronizationOptions()
+
+ @Category(name = "Debug", desc = "")
+ @JvmField
+ val debug = DebugOptions()
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/MainOptions.kt b/mod/src/main/kotlin/moe/nea/ledger/config/MainOptions.kt
new file mode 100644
index 0000000..1efa970
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/MainOptions.kt
@@ -0,0 +1,27 @@
+package moe.nea.ledger.config
+
+import io.github.notenoughupdates.moulconfig.annotations.ConfigEditorDropdown
+import io.github.notenoughupdates.moulconfig.annotations.ConfigOption
+
+class MainOptions {
+ @ConfigOption(name = "Marker for Update UI", desc = "_")
+ @JvmField
+ @UpdateUiMarker
+ @Transient
+ var testOption = Unit
+
+ @ConfigOption(name = "Check for Updates", desc = "Automatically check for updates on startup")
+ @ConfigEditorDropdown
+ @JvmField
+ var updateCheck = UpdateCheckBehaviour.SEMI
+
+ enum class UpdateCheckBehaviour(val label: String) {
+ SEMI("Semi-Automatic"),
+ FULL("Full-Automatic"),
+ NONE("Don't check");
+
+ override fun toString(): String {
+ return label
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/SynchronizationOptions.kt b/mod/src/main/kotlin/moe/nea/ledger/config/SynchronizationOptions.kt
new file mode 100644
index 0000000..b8c740b
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/SynchronizationOptions.kt
@@ -0,0 +1,11 @@
+package moe.nea.ledger.config
+
+import io.github.notenoughupdates.moulconfig.annotations.ConfigEditorBoolean
+import io.github.notenoughupdates.moulconfig.annotations.ConfigOption
+
+class SynchronizationOptions {
+ @ConfigOption(name = "Test Option", desc = "Test Description")
+ @ConfigEditorBoolean
+ @JvmField
+ var testOption = false
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUi.kt b/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUi.kt
new file mode 100644
index 0000000..86ccbf7
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUi.kt
@@ -0,0 +1,17 @@
+package moe.nea.ledger.config
+
+import io.github.notenoughupdates.moulconfig.gui.GuiComponent
+import io.github.notenoughupdates.moulconfig.gui.component.TextComponent
+import io.github.notenoughupdates.moulconfig.gui.editors.ComponentEditor
+import io.github.notenoughupdates.moulconfig.processor.ProcessedOption
+import moe.nea.ledger.Ledger
+
+class UpdateUi(option: ProcessedOption) : ComponentEditor(option) {
+ val delegate by lazy {// TODO
+ TextComponent("Hier könnte ihre werbung stehen")
+ }
+
+ override fun getDelegate(): GuiComponent {
+ return delegate
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUiMarker.kt b/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUiMarker.kt
new file mode 100644
index 0000000..7a0466a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/config/UpdateUiMarker.kt
@@ -0,0 +1,6 @@
+package moe.nea.ledger.config
+
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.FIELD)
+annotation class UpdateUiMarker {
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/database/DBLogEntry.kt b/mod/src/main/kotlin/moe/nea/ledger/database/DBLogEntry.kt
new file mode 100644
index 0000000..b162c6f
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/database/DBLogEntry.kt
@@ -0,0 +1,24 @@
+package moe.nea.ledger.database
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.database.columns.DBDouble
+import moe.nea.ledger.database.columns.DBEnum
+import moe.nea.ledger.database.columns.DBString
+import moe.nea.ledger.database.columns.DBUlid
+import moe.nea.ledger.database.columns.DBUuid
+
+object DBLogEntry : Table("LogEntry") {
+ val transactionId = column("transactionId", DBUlid)
+ val type = column("type", DBEnum<TransactionType>())
+ val profileId = column("profileId", DBUuid)
+ val playerId = column("playerId", DBUuid)
+}
+
+object DBItemEntry : Table("ItemEntry") {
+ val transactionId = column("transactionId", DBUlid) // TODO: add foreign keys
+ val mode = column("mode", DBEnum<ItemChange.ChangeDirection>())
+ val itemId = column("item", DBString.mapped(ItemId::string, ::ItemId))
+ val size = column("size", DBDouble)
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/database/DBUpgrade.kt b/mod/src/main/kotlin/moe/nea/ledger/database/DBUpgrade.kt
new file mode 100644
index 0000000..7d1782a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/database/DBUpgrade.kt
@@ -0,0 +1,68 @@
+package moe.nea.ledger.database
+
+import java.sql.Connection
+
+interface DBUpgrade {
+ val toVersion: Long
+ val fromVersion get() = toVersion - 1
+ fun performUpgrade(connection: Connection)
+
+ companion object {
+
+ fun performUpgrades(
+ connection: Connection,
+ upgrades: Iterable<DBUpgrade>,
+ ) {
+ for (upgrade in upgrades) {
+ upgrade.performUpgrade(connection)
+ }
+ }
+
+ fun performUpgradeChain(
+ connection: Connection,
+ from: Long, to: Long,
+ upgrades: Iterable<DBUpgrade>,
+ afterEach: (newVersion: Long) -> Unit,
+ ) {
+ val table = buildLookup(upgrades)
+ for (version in (from + 1)..(to)) {
+ val currentUpgrades = table[version] ?: listOf()
+ println("Scheduled ${currentUpgrades.size} upgrades to reach DB version $version")
+ performUpgrades(connection, currentUpgrades)
+ afterEach(version)
+ }
+ }
+
+ fun buildLookup(upgrades: Iterable<DBUpgrade>): Map<Long, List<DBUpgrade>> {
+ return upgrades.groupBy { it.toVersion }
+ }
+
+ fun createTable(to: Long, table: Table, vararg columns: Column<*>): DBUpgrade {
+ require(columns.all { it in table.columns })
+ return of("Create table ${table}", to) {
+ table.createIfNotExists(it, columns.toList())
+ }
+ }
+
+ fun addColumns(to: Long, table: Table, vararg columns: Column<*>): DBUpgrade {
+ return of("Add columns to table $table", to) {
+ table.alterTableAddColumns(it, columns.toList())
+ }
+ }
+
+ fun of(name: String, to: Long, block: (Connection) -> Unit): DBUpgrade {
+ return object : DBUpgrade {
+ override val toVersion: Long
+ get() = to
+
+ override fun performUpgrade(connection: Connection) {
+ block(connection)
+ }
+
+ override fun toString(): String {
+ return name
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/database/Database.kt b/mod/src/main/kotlin/moe/nea/ledger/database/Database.kt
new file mode 100644
index 0000000..025888c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/database/Database.kt
@@ -0,0 +1,57 @@
+package moe.nea.ledger.database
+
+import moe.nea.ledger.Ledger
+import moe.nea.ledger.database.columns.DBString
+import java.sql.Connection
+import java.sql.DriverManager
+
+class Database {
+ lateinit var connection: Connection
+
+ object MetaTable : Table("LedgerMeta") {
+ val key = column("key", DBString)
+ val value = column("value", DBString)
+
+ init {
+ unique(key)
+ }
+ }
+
+ data class MetaKey(val name: String) {
+ companion object {
+ val DATABASE_VERSION = MetaKey("databaseVersion")
+ val LAST_LAUNCH = MetaKey("lastLaunch")
+ }
+ }
+
+ fun setMetaKey(key: MetaKey, value: String) {
+ MetaTable.insert(connection, Table.OnConflict.REPLACE) {
+ it[MetaTable.key] = key.name
+ it[MetaTable.value] = value
+ }
+ }
+
+ val databaseVersion: Long = 1
+
+ fun loadAndUpgrade() {
+ connection = DriverManager.getConnection("jdbc:sqlite:${Ledger.dataFolder.resolve("database.db")}")
+ MetaTable.createIfNotExists(connection)
+ val meta = MetaTable.selectAll(connection).associate { MetaKey(it[MetaTable.key]) to it[MetaTable.value] }
+ val lastLaunch = meta[MetaKey.LAST_LAUNCH]?.toLong() ?: 0L
+ println("Last launch $lastLaunch")
+ setMetaKey(MetaKey.LAST_LAUNCH, System.currentTimeMillis().toString())
+
+ val oldVersion = meta[MetaKey.DATABASE_VERSION]?.toLong() ?: -1
+ println("Old Database Version: $oldVersion; Current version: $databaseVersion")
+ if (oldVersion > databaseVersion)
+ error("Outdated software. Database is newer than me!")
+ // TODO: create a backup if there is a db version upgrade happening
+ DBUpgrade.performUpgradeChain(
+ connection, oldVersion, databaseVersion,
+ Upgrades().upgrades
+ ) { version ->
+ setMetaKey(MetaKey.DATABASE_VERSION, version.toString())
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/database/Upgrades.kt b/mod/src/main/kotlin/moe/nea/ledger/database/Upgrades.kt
new file mode 100644
index 0000000..e83abe7
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/database/Upgrades.kt
@@ -0,0 +1,20 @@
+package moe.nea.ledger.database
+
+class Upgrades {
+ val upgrades = mutableListOf<DBUpgrade>()
+
+ fun add(upgrade: DBUpgrade) = upgrades.add(upgrade)
+
+ init {
+ add(DBUpgrade.createTable(
+ 0, DBLogEntry,
+ DBLogEntry.type, DBLogEntry.playerId, DBLogEntry.profileId,
+ DBLogEntry.transactionId))
+ add(DBUpgrade.createTable(
+ 0, DBItemEntry,
+ DBItemEntry.itemId, DBItemEntry.size, DBItemEntry.mode, DBItemEntry.transactionId
+ ))
+ }
+
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/database/schema.dot b/mod/src/main/kotlin/moe/nea/ledger/database/schema.dot
new file mode 100644
index 0000000..d932f6a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/database/schema.dot
@@ -0,0 +1,23 @@
+digraph {
+ node [shape=plain];
+ rankdir=LR;
+ entry [label=<
+ <table border="0" cellborder="1" cellspacing="0">
+ <tr><td>Log Entry</td></tr>
+ <tr><td port="player">playerId</td></tr>
+ <tr><td port="profile">profileId</td></tr>
+ <tr><td port="date">timestamp</td></tr>
+ <tr><td port="type">Type</td></tr>
+ </table>
+ >];
+ item [label=<
+ <table border="0" cellborder="1" cellspacing="0">
+ <tr><td>Item Stack</td><tr>
+ <tr><td port="transaction">Transaction</td></tr>
+ <tr><td port="id">Item ID</td></tr>
+ <tr><td port="count">Count</td></tr>
+ <tr><td port="direction">Transfer Direction</td></tr>
+ </table>
+ >];
+// item:transaction -> entry;
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/BeforeGuiAction.kt b/mod/src/main/kotlin/moe/nea/ledger/events/BeforeGuiAction.kt
new file mode 100644
index 0000000..098912a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/BeforeGuiAction.kt
@@ -0,0 +1,11 @@
+package moe.nea.ledger.events
+
+import net.minecraft.client.gui.GuiScreen
+import net.minecraft.client.gui.inventory.GuiChest
+import net.minecraft.inventory.ContainerChest
+import net.minecraftforge.fml.common.eventhandler.Event
+
+data class BeforeGuiAction(val gui: GuiScreen) : Event() {
+ val chest = gui as? GuiChest
+ val chestSlots = chest?.inventorySlots as ContainerChest?
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/ChatReceived.kt b/mod/src/main/kotlin/moe/nea/ledger/events/ChatReceived.kt
new file mode 100644
index 0000000..a352c27
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/ChatReceived.kt
@@ -0,0 +1,15 @@
+package moe.nea.ledger.events
+
+import moe.nea.ledger.unformattedString
+import net.minecraftforge.client.event.ClientChatReceivedEvent
+import net.minecraftforge.fml.common.eventhandler.Event
+import java.time.Instant
+
+data class ChatReceived(
+ val message: String,
+ val timestamp: Instant = Instant.now()
+) : Event() {
+ constructor(event: ClientChatReceivedEvent) : this(
+ event.message.unformattedText.unformattedString().trimEnd()
+ )
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/ExtraSupplyIdEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/ExtraSupplyIdEvent.kt
new file mode 100644
index 0000000..d040961
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/ExtraSupplyIdEvent.kt
@@ -0,0 +1,12 @@
+package moe.nea.ledger.events
+
+import moe.nea.ledger.ItemId
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class ExtraSupplyIdEvent(
+ private val store: (String, ItemId) -> Unit
+) : Event() {
+ fun store(name: String, id: ItemId) {
+ store.invoke(name, id)
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/GuiClickEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/GuiClickEvent.kt
new file mode 100644
index 0000000..9e057dd
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/GuiClickEvent.kt
@@ -0,0 +1,9 @@
+package moe.nea.ledger.events
+
+import net.minecraft.inventory.Slot
+import net.minecraftforge.fml.common.eventhandler.Event
+
+data class GuiClickEvent(
+ val slotIn: Slot?, val slotId: Int, val clickedButton: Int, val clickType: Int
+) : Event() {
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/InitializationComplete.kt b/mod/src/main/kotlin/moe/nea/ledger/events/InitializationComplete.kt
new file mode 100644
index 0000000..d917039
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/InitializationComplete.kt
@@ -0,0 +1,6 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class InitializationComplete : Event() {
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/RegistrationFinishedEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/RegistrationFinishedEvent.kt
new file mode 100644
index 0000000..d36e0c7
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/RegistrationFinishedEvent.kt
@@ -0,0 +1,7 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class RegistrationFinishedEvent : Event() {
+
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/SupplyDebugInfo.kt b/mod/src/main/kotlin/moe/nea/ledger/events/SupplyDebugInfo.kt
new file mode 100644
index 0000000..cab0a20
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/SupplyDebugInfo.kt
@@ -0,0 +1,10 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class SupplyDebugInfo : Event() { // TODO: collect this in the event recorder
+ val data = mutableListOf<Pair<String, Any>>()
+ fun record(key: String, value: Any) {
+ data.add(key to value)
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/TriggerEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/TriggerEvent.kt
new file mode 100644
index 0000000..3751f43
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/TriggerEvent.kt
@@ -0,0 +1,7 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Cancelable
+import net.minecraftforge.fml.common.eventhandler.Event
+
+@Cancelable
+data class TriggerEvent(val action: String) : Event()
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/WorldLoadEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/WorldLoadEvent.kt
new file mode 100644
index 0000000..d60f3a4
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/WorldLoadEvent.kt
@@ -0,0 +1,5 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class LateWorldLoadEvent : Event()
diff --git a/mod/src/main/kotlin/moe/nea/ledger/events/WorldSwitchEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/events/WorldSwitchEvent.kt
new file mode 100644
index 0000000..22a97f7
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/events/WorldSwitchEvent.kt
@@ -0,0 +1,6 @@
+package moe.nea.ledger.events
+
+import net.minecraftforge.fml.common.eventhandler.Event
+
+class WorldSwitchEvent : Event() {
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/AccessorySwapperDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/AccessorySwapperDetection.kt
new file mode 100644
index 0000000..1c228ff
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/AccessorySwapperDetection.kt
@@ -0,0 +1,34 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class AccessorySwapperDetection {
+
+ val swapperUsed = "Swapped .* enrichments to .*!".toPattern()
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ swapperUsed.useMatcher(event.message) {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.ACCESSORIES_SWAPPING,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(ItemIds.TALISMAN_ENRICHMENT_SWAPPER, 1)
+ )
+ )
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/AllowanceDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/AllowanceDetection.kt
new file mode 100644
index 0000000..cd48d45
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/AllowanceDetection.kt
@@ -0,0 +1,37 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+
+class AllowanceDetection {
+
+ val allowancePattern =
+ Pattern.compile("ALLOWANCE! You earned (?<coins>$SHORT_NUMBER_PATTERN) coins!")
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @SubscribeEvent
+ fun onAllowanceGain(event: ChatReceived) {
+ allowancePattern.useMatcher(event.message) {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.ALLOWANCE_GAIN,
+ event.timestamp,
+ listOf(
+ ItemChange.gainCoins(parseShortNumber(group("coins"))),
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/AuctionHouseDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/AuctionHouseDetection.kt
new file mode 100644
index 0000000..d02095d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/AuctionHouseDetection.kt
@@ -0,0 +1,143 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ExpiringValue
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.BeforeGuiAction
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.getLore
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.client.gui.inventory.GuiChest
+import net.minecraft.inventory.ContainerChest
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+import kotlin.time.Duration.Companion.seconds
+
+class AuctionHouseDetection @Inject constructor(val ledger: LedgerLogger, val ids: ItemIdProvider) {
+ data class LastViewedItem(
+ val count: Int,
+ val id: ItemId,
+ )
+ /*
+ You collected 8,712,000 coins from selling Ultimate Carrot Candy Upgrade to [VIP] kodokush in an auction!
+ You collected 60,000 coins from selling Walnut to [MVP++] Alea1337 in an auction!
+ You purchased 2x Walnut for 69 coins!
+ You purchased ◆ Ice Rune I for 4,000 coins!
+ */
+
+ val createAuctionScreen = "Confirm( BIN)? Auction".toPattern()
+ val auctionCreationCostPattern = "Cost: (?<cost>$SHORT_NUMBER_PATTERN) coins?".toPattern()
+
+ val auctionCreatedChatPattern = "(BIN )?Auction started for .*".toPattern()
+
+ var lastCreationCost: ExpiringValue<Double> = ExpiringValue.empty()
+
+ @SubscribeEvent
+ fun onCreateAuctionClick(event: BeforeGuiAction) {
+ val slots = event.chestSlots ?: return
+ if (!createAuctionScreen.asPredicate().test(slots.lowerChestInventory.name)) return
+ val auctionSlot = slots.lowerChestInventory.getStackInSlot(9 + 2) ?: return
+ val creationCost = auctionSlot.getLore().firstNotNullOfOrNull {
+ auctionCreationCostPattern.useMatcher(it.unformattedString()) { parseShortNumber(group("cost")) }
+ }
+ if (creationCost != null) {
+ lastCreationCost = ExpiringValue(creationCost)
+ }
+ }
+
+ @SubscribeEvent
+ fun onCreateAuctionChat(event: ChatReceived) {
+ auctionCreatedChatPattern.useMatcher(event.message) {
+ lastCreationCost.consume(3.seconds)?.let { cost ->
+ ledger.logEntry(LedgerEntry(
+ TransactionType.AUCTION_LISTING_CHARGE,
+ event.timestamp,
+ listOf(ItemChange.loseCoins(cost))
+ ))
+ }
+ }
+ }
+
+ val collectSold =
+ Pattern.compile("You collected (?<coins>$SHORT_NUMBER_PATTERN) coins? from selling (?<what>.*) to (?<buyer>.*) in an auction!")
+ val purchased =
+ Pattern.compile("You purchased (?:(?<amount>[0-9]+)x )?(?<what>.*) for (?<coins>$SHORT_NUMBER_PATTERN) coins!")
+ var lastViewedItems: MutableList<LastViewedItem> = mutableListOf()
+
+ @SubscribeEvent
+ fun onEvent(event: ChatReceived) {
+ collectSold.useMatcher(event.message) {
+ val lastViewedItem = lastViewedItems.removeLastOrNull()
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.AUCTION_SOLD,
+ event.timestamp,
+ listOfNotNull(
+ ItemChange.gainCoins(parseShortNumber(group("coins"))),
+ lastViewedItem?.let { ItemChange.lose(it.id, it.count) }
+ ),
+ )
+ )
+ }
+ purchased.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.AUCTION_BOUGHT,
+ event.timestamp,
+ listOf(
+ ItemChange.loseCoins(parseShortNumber(group("coins"))),
+ ItemChange.gain(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ group("amount")?.toInt() ?: 1
+ )
+ )
+ )
+ )
+ }
+ }
+
+ @SubscribeEvent
+ fun onBeforeAuctionCollected(event: BeforeGuiAction) {
+ val chest = (event.gui as? GuiChest) ?: return
+ val slots = chest.inventorySlots as ContainerChest
+ val name = slots.lowerChestInventory.displayName.unformattedText.unformattedString()
+
+ if (name == "BIN Auction View" || name == "Auction View") {
+ handleCollectSingleAuctionView(slots)
+ }
+ if (name == "Manage Auctions") {
+ handleCollectMultipleAuctionsView(slots)
+ }
+ }
+
+ private fun handleCollectMultipleAuctionsView(slots: ContainerChest) {
+ lastViewedItems =
+ (0 until slots.lowerChestInventory.sizeInventory)
+ .mapNotNull { slots.lowerChestInventory.getStackInSlot(it) }
+ .filter {
+ it.getLore().contains("§7Status: §aSold!") // BINs
+ || it.getLore().contains("§7Status: §aEnded!") // Auctions
+ }
+ .mapNotNull { LastViewedItem(it.stackSize, it.getInternalId() ?: return@mapNotNull null) }
+ .toMutableList()
+ }
+
+
+ fun handleCollectSingleAuctionView(slots: ContainerChest) {
+ val soldItem = slots.lowerChestInventory.getStackInSlot(9 + 4) ?: return
+ val id = soldItem.getInternalId() ?: return
+ val count = soldItem.stackSize
+ lastViewedItems = mutableListOf(LastViewedItem(count, id))
+ }
+
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/BankDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/BankDetection.kt
new file mode 100644
index 0000000..928d30c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/BankDetection.kt
@@ -0,0 +1,49 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+
+
+class BankDetection @Inject constructor(val ledger: LedgerLogger) {
+ val withdrawPattern =
+ Pattern.compile("^(You have withdrawn|Withdrew) (?<amount>$SHORT_NUMBER_PATTERN) coins?! (?:There's now|You now have) (?<newtotal>$SHORT_NUMBER_PATTERN) coins? (?:left in the account!|in your account!)$")
+ val depositPattern =
+ Pattern.compile("^(?:You have deposited|Deposited) (?<amount>$SHORT_NUMBER_PATTERN) coins?! (?:There's now|You now have) (?<newtotal>$SHORT_NUMBER_PATTERN) coins? (?:in your account!|in the account!)$")
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ withdrawPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BANK_WITHDRAW,
+ event.timestamp,
+ listOf(ItemChange(ItemId.COINS,
+ parseShortNumber(group("amount")),
+ ItemChange.ChangeDirection.TRANSFORM)),
+ )
+ )
+ }
+ depositPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BANK_DEPOSIT,
+ event.timestamp,
+ listOf(ItemChange(ItemId.COINS,
+ parseShortNumber(group("amount")),
+ ItemChange.ChangeDirection.TRANSFORM)),
+ )
+ )
+ }
+ }
+
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarDetection.kt
new file mode 100644
index 0000000..0f1fc2c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarDetection.kt
@@ -0,0 +1,58 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+
+class BazaarDetection @Inject constructor(val ledger: LedgerLogger, val ids: ItemIdProvider) {
+
+ val instaBuyPattern =
+ Pattern.compile("\\[Bazaar\\] Bought (?<count>$SHORT_NUMBER_PATTERN)x (?<what>.*) for (?<coins>$SHORT_NUMBER_PATTERN) coins!")
+ val instaSellPattern =
+ Pattern.compile("\\[Bazaar\\] Sold (?<count>$SHORT_NUMBER_PATTERN)x (?<what>.*) for (?<coins>$SHORT_NUMBER_PATTERN) coins!")
+
+
+ @SubscribeEvent
+ fun onInstSellChat(event: ChatReceived) {
+ instaBuyPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BAZAAR_BUY_INSTANT,
+ event.timestamp,
+ listOf(
+ ItemChange.loseCoins(parseShortNumber(group("coins"))),
+ ItemChange.gain(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ parseShortNumber(group("count"))
+ )
+ )
+ )
+ )
+ }
+ instaSellPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BAZAAR_SELL_INSTANT,
+ event.timestamp,
+ listOf(
+ ItemChange.gainCoins(parseShortNumber(group("coins"))),
+ ItemChange.lose(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ parseShortNumber(group("count"))
+ )
+ ),
+ )
+ )
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarOrderDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarOrderDetection.kt
new file mode 100644
index 0000000..330ee1d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/BazaarOrderDetection.kt
@@ -0,0 +1,95 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.mixin.AccessorGuiEditSign
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.client.gui.inventory.GuiEditSign
+import net.minecraftforge.client.event.GuiScreenEvent
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+
+class BazaarOrderDetection @Inject constructor(val ledger: LedgerLogger, val ids: ItemIdProvider) {
+
+ val buyOrderClaimed =
+ Pattern.compile("\\[Bazaar] Claimed (?<amount>$SHORT_NUMBER_PATTERN)x (?<what>.*) worth (?<coins>$SHORT_NUMBER_PATTERN) coins? bought for $SHORT_NUMBER_PATTERN each!")
+ val sellOrderClaimed =
+ Pattern.compile("\\[Bazaar] Claimed (?<coins>$SHORT_NUMBER_PATTERN) coins? from selling (?<amount>$SHORT_NUMBER_PATTERN)x (?<what>.*) at $SHORT_NUMBER_PATTERN each!")
+ val orderFlipped =
+ Pattern.compile("\\[Bazaar] Order Flipped! (?<amount>$SHORT_NUMBER_PATTERN)x (?<what>.*) for (?<coins>$SHORT_NUMBER_PATTERN) coins? of total expected profit.")
+ val previousPricePattern =
+ Pattern.compile("(?<price>$SHORT_NUMBER_PATTERN)/u")
+ var lastFlippedPreviousPrice = 0.0
+
+ @SubscribeEvent
+ fun detectSignFlip(event: GuiScreenEvent.InitGuiEvent) {
+ val gui = event.gui
+ if (gui !is GuiEditSign) return
+ gui as AccessorGuiEditSign
+ val text = gui.tileEntity_ledger.signText
+ if (text[2].unformattedText != "Previous price:") return
+ previousPricePattern.useMatcher(text[3].unformattedText) {
+ lastFlippedPreviousPrice = parseShortNumber(group("price"))
+ }
+ }
+
+ @SubscribeEvent
+ fun detectBuyOrders(event: ChatReceived) {
+ orderFlipped.useMatcher(event.message) {
+ val amount = parseShortNumber(group("amount")).toInt()
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BAZAAR_BUY_ORDER,
+ event.timestamp,
+ listOf(
+ ItemChange.loseCoins(lastFlippedPreviousPrice * amount),
+ ItemChange.gain(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ amount,
+ )
+ )
+ )
+ )
+ }
+ buyOrderClaimed.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BAZAAR_BUY_ORDER,
+ event.timestamp,
+ listOf(
+ ItemChange.loseCoins(parseShortNumber(group("coins"))),
+ ItemChange.gain(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ parseShortNumber(group("amount"))
+ )
+ ),
+ )
+ )
+ }
+ sellOrderClaimed.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BAZAAR_SELL_ORDER,
+ event.timestamp,
+ listOf(
+ ItemChange.gainCoins(
+ parseShortNumber(group("coins"))
+ ),
+ ItemChange.lose(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ parseShortNumber(group("amount")),
+ )
+ ),
+ )
+ )
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/BitsDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/BitsDetection.kt
new file mode 100644
index 0000000..44a0050
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/BitsDetection.kt
@@ -0,0 +1,62 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.LateWorldLoadEvent
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.ScoreboardUtil
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+
+class BitsDetection @Inject constructor(val ledger: LedgerLogger) {
+
+ var lastBits = -1
+
+ val bitScoreboardRegex = "Bits: (?<purse>$SHORT_NUMBER_PATTERN)".toPattern()
+
+ @SubscribeEvent
+ fun onWorldSwitch(event: LateWorldLoadEvent) {
+ ScoreboardUtil.getScoreboardStrings().forEach {
+ bitScoreboardRegex.useMatcher<Unit>(it.unformattedString()) {
+ val bits = parseShortNumber(group("purse")).toInt()
+ if (lastBits != bits) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BITS_PURSE_STATUS,
+ Instant.now(),
+ listOf(
+ ItemChange(ItemIds.SKYBLOCK_BIT, bits.toDouble(), ItemChange.ChangeDirection.SYNC)
+ )
+ )
+ )
+ lastBits = bits
+ }
+ return
+ }
+ }
+ }
+
+ @SubscribeEvent
+ fun onEvent(event: ChatReceived) {
+ if (event.message.startsWith("You consumed a Booster Cookie!")) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.BOOSTER_COOKIE_ATE,
+ Instant.now(),
+ listOf(
+ ItemChange.lose(ItemIds.BOOSTER_COOKIE, 1)
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/BitsShopDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/BitsShopDetection.kt
new file mode 100644
index 0000000..553bebf
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/BitsShopDetection.kt
@@ -0,0 +1,66 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.GuiClickEvent
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.getLore
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+
+class BitsShopDetection @Inject constructor(val ledger: LedgerLogger) {
+
+
+ data class BitShopEntry(
+ val id: ItemId,
+ val stackSize: Int,
+ val bitPrice: Int,
+ val timestamp: Long = System.currentTimeMillis()
+ )
+
+ var lastClickedBitShopItem: BitShopEntry? = null
+ var bitCostPattern = "(?<cost>$SHORT_NUMBER_PATTERN) Bits".toPattern()
+
+ @SubscribeEvent
+ fun recordLastBitPrice(event: GuiClickEvent) {
+ val slot = event.slotIn ?: return
+ val name = slot.inventory.displayName.unformattedText.unformattedString()
+ if (name != "Community Shop" && !name.startsWith("Bits Shop"))
+ return
+ val stack = slot.stack ?: return
+ val id = stack.getInternalId() ?: return
+ val bitPrice = stack.getLore()
+ .firstNotNullOfOrNull { bitCostPattern.useMatcher(it.unformattedString()) { parseShortNumber(group("cost")).toInt() } }
+ ?: return
+ lastClickedBitShopItem = BitShopEntry(id, stack.stackSize, bitPrice)
+ }
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ if (event.message.startsWith("You bought ")) {
+ val lastBit = lastClickedBitShopItem ?: return
+ if (System.currentTimeMillis() - lastBit.timestamp > 5000) return
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.COMMUNITY_SHOP_BUY,
+ Instant.now(),
+ listOf(
+ ItemChange.lose(ItemIds.SKYBLOCK_BIT, lastBit.bitPrice.toDouble()),
+ ItemChange.gain(lastBit.id, lastBit.stackSize)
+ )
+ )
+ )
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/ChestDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/ChestDetection.kt
new file mode 100644
index 0000000..cca02e1
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/ChestDetection.kt
@@ -0,0 +1,48 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.getDisplayNameU
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.getLore
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.init.Blocks
+import net.minecraft.inventory.Slot
+import net.minecraft.item.Item
+import java.time.Instant
+
+abstract class ChestDetection {
+ data class ChestCost(
+ val diff: List<ItemChange>,
+ val timestamp: Instant,
+ )
+
+ @Inject
+ lateinit var itemIdProvider: ItemIdProvider
+ fun scrapeChestReward(rewardSlot: Slot): ChestCost? {
+ val inventory = rewardSlot.inventory
+ if (!inventory.displayName.unformattedText.unformattedString()
+ .endsWith(" Chest")
+ ) return null
+ val rewardStack = rewardSlot.stack ?: return null
+ val name = rewardStack.getDisplayNameU()
+ if (name != "§aOpen Reward Chest") return null
+ val lore = rewardStack.getLore()
+ val cost = itemIdProvider.findCostItemsFromSpan(lore)
+ val gain = (9..18)
+ .mapNotNull { inventory.getStackInSlot(it) }
+ .filter { it.item != Item.getItemFromBlock(Blocks.stained_glass_pane) }
+ .map {
+ it.getInternalId()?.withStackSize(it.stackSize)
+ ?: itemIdProvider.findStackableItemByName(it.displayName)
+ ?: ItemId.NIL.withStackSize(it.stackSize)
+ }
+ return ChestCost(
+ cost.map { ItemChange.lose(it.first, it.second) } + gain.map { ItemChange.gain(it.first, it.second) },
+ Instant.now()
+ )
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/DragonEyePlacementDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/DragonEyePlacementDetection.kt
new file mode 100644
index 0000000..e389ffb
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/DragonEyePlacementDetection.kt
@@ -0,0 +1,47 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.WorldSwitchEvent
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class DragonEyePlacementDetection {
+ val eyePlaced = "☬ You placed a Summoning Eye!( Brace yourselves!)? \\(./.\\)".toPattern()
+//☬ You placed a Summoning Eye! Brace yourselves! (8/8)
+ var eyeCount = 0
+
+ @SubscribeEvent
+ fun onWorldSwap(event: WorldSwitchEvent) {
+ eyeCount = 0
+ }
+
+ @SubscribeEvent
+ fun onRetrieveEye(event: ChatReceived) {
+ if (event.message == "You recovered a Summoning Eye!") {
+ eyeCount--
+ }
+ eyePlaced.useMatcher(event.message) {
+ eyeCount++
+ }
+ if (event.message == "Your Sleeping Eyes have been awoken by the magic of the Dragon. They are now Remnants of the Eye!") {
+ logger.logEntry(LedgerEntry(
+ TransactionType.WYRM_EVOKED,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(ItemIds.SUMMONING_EYE, eyeCount)
+ )
+ ))
+ eyeCount = 0
+ }
+ }
+
+ @Inject
+ lateinit var logger: LedgerLogger
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/DragonSacrificeDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/DragonSacrificeDetection.kt
new file mode 100644
index 0000000..574cfcf
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/DragonSacrificeDetection.kt
@@ -0,0 +1,72 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.DebouncedValue
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import net.minecraftforge.fml.common.gameevent.TickEvent
+import kotlin.time.Duration.Companion.seconds
+
+class DragonSacrificeDetection {
+ //SACRIFICE! You turned Holy Dragon Boots into 30 Dragon Essence!
+ //BONUS LOOT! You also received 17x Holy Dragon Fragment from your sacrifice!
+ @Inject
+ lateinit var itemIdProvider: ItemIdProvider
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ val sacrificePattern =
+ "SACRIFICE! You turned (?<sacrifice>.*) into (?<amount>$SHORT_NUMBER_PATTERN) Dragon Essence!".toPattern()
+ val bonusLootPattern = "BONUS LOOT! You also received (?<bonus>.*) from your sacrifice!".toPattern()
+
+ var lastSacrifice: DebouncedValue<LedgerEntry> = DebouncedValue.farFuture()
+
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ sacrificePattern.useMatcher(event.message) {
+ val sacrifice = itemIdProvider.findForName(group("sacrifice")) ?: return
+ val lootEssence = parseShortNumber(group("amount"))
+ consume(lastSacrifice.replace())
+ lastSacrifice = DebouncedValue(LedgerEntry(
+ TransactionType.DRACONIC_SACRIFICE,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(sacrifice, 1),
+ ItemChange.gain(ItemIds.ESSENCE_DRAGON, lootEssence)
+ )
+ ))
+ }
+ bonusLootPattern.useMatcher(event.message) {
+ val bonusItem = itemIdProvider.findStackableItemByName(
+ group("bonus"), true
+ ) ?: return
+ lastSacrifice.replace()?.let {
+ consume(
+ it.copy(items = it.items + ItemChange.unpairGain(bonusItem))
+ )
+ }
+ }
+ }
+
+ @SubscribeEvent
+ fun onTick(event: TickEvent) {
+ consume(lastSacrifice.consume(4.seconds))
+ }
+
+ fun consume(entry: LedgerEntry?) {
+ if (entry != null)
+ logger.logEntry(entry)
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/DungeonChestDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/DungeonChestDetection.kt
new file mode 100644
index 0000000..e747be9
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/DungeonChestDetection.kt
@@ -0,0 +1,95 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ExpiringValue
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.ExtraSupplyIdEvent
+import moe.nea.ledger.events.GuiClickEvent
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.getDisplayNameU
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.time.Duration.Companion.seconds
+
+class DungeonChestDetection @Inject constructor(val logger: LedgerLogger) : ChestDetection() {
+
+ @SubscribeEvent
+ fun onKismetClick(event: GuiClickEvent) {
+ val slot = event.slotIn ?: return
+ if (!slot.inventory.displayName.unformattedText.unformattedString().endsWith(" Chest")) return
+ val stack = slot.stack ?: return
+ if (stack.getDisplayNameU() == "§aReroll Chest") {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.KISMET_REROLL,
+ Instant.now(),
+ listOf(
+ ItemChange.lose(ItemIds.KISMET_FEATHER, 1)
+ )
+ )
+ )
+ }
+ }
+
+
+ var lastOpenedChest = ExpiringValue.empty<ChestCost>()
+
+ @SubscribeEvent
+ fun supplyExtraIds(event: ExtraSupplyIdEvent) {
+ event.store("Dungeon Chest Key", ItemIds.DUNGEON_CHEST_KEY)
+ event.store("Kismet Feather", ItemIds.KISMET_FEATHER)
+ }
+
+ @SubscribeEvent
+ fun onRewardChestClick(event: GuiClickEvent) {
+ lastOpenedChest = ExpiringValue(scrapeChestReward(event.slotIn ?: return) ?: return)
+ }
+
+ class Mutex<T>(defaultValue: T) {
+ private var value: T = defaultValue
+ val lock = ReentrantLock()
+
+ fun getUnsafeLockedValue(): T {
+ if (!lock.isHeldByCurrentThread)
+ error("Accessed unsafe locked value, without holding the lock.")
+ return value
+ }
+
+ fun <R> withLock(func: (T) -> R): R {
+ lock.lockInterruptibly()
+ try {
+ val ret = func(value)
+ if (ret === value) {
+ error("Please don't smuggle out the locked value. If this is unintentional, please append a `Unit` instruction to the end of your `withLock` call: `.withLock { /* your existing code */; Unit }`.")
+ }
+ return ret
+ } finally {
+ lock.unlock()
+ }
+ }
+ }
+
+ val rewardMessage = " (WOOD|GOLD|DIAMOND|EMERALD|OBSIDIAN|BEDROCK) CHEST REWARDS".toPattern()
+
+ @SubscribeEvent
+ fun onChatMessage(event: ChatReceived) {
+ if (event.message == "You don't have that many coins in the bank!") {
+ lastOpenedChest.take()
+ }
+ rewardMessage.useMatcher(event.message) {
+ val chest = lastOpenedChest.consume(3.seconds) ?: return
+ logger.logEntry(LedgerEntry(
+ TransactionType.DUNGEON_CHEST_OPEN,
+ chest.timestamp,
+ chest.diff,
+ ))
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/ExternalDataProvider.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/ExternalDataProvider.kt
new file mode 100644
index 0000000..93bb453
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/ExternalDataProvider.kt
@@ -0,0 +1,43 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.events.InitializationComplete
+import moe.nea.ledger.events.SupplyDebugInfo
+import moe.nea.ledger.utils.GsonUtil
+import moe.nea.ledger.utils.di.Inject
+import moe.nea.ledger.utils.network.Request
+import moe.nea.ledger.utils.network.RequestUtil
+import net.minecraftforge.common.MinecraftForge
+import net.minecraftforge.fml.common.eventhandler.Event
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.concurrent.CompletableFuture
+
+class ExternalDataProvider @Inject constructor(
+ val requestUtil: RequestUtil
+) {
+
+ fun createAuxillaryDataRequest(path: String): Request {
+ return requestUtil.createRequest("https://github.com/nea89o/ledger-auxiliary-data/raw/refs/heads/master/$path")
+ }
+
+ private val itemNameFuture: CompletableFuture<Map<String, String>> = CompletableFuture.supplyAsync {
+ val request = createAuxillaryDataRequest("data/item_names.json")
+ val response = request.execute(requestUtil)
+ val nameMap = response.json(GsonUtil.typeToken<Map<String, String>>())
+ return@supplyAsync nameMap
+ }
+
+ lateinit var itemNames: Map<String, String>
+
+ class DataLoaded(val provider: ExternalDataProvider) : Event()
+
+ @SubscribeEvent
+ fun onDebugData(debugInfo: SupplyDebugInfo) {
+ debugInfo.record("externalItemsLoaded", itemNameFuture.isDone && !itemNameFuture.isCompletedExceptionally)
+ }
+
+ @SubscribeEvent
+ fun onInitComplete(event: InitializationComplete) {
+ itemNames = itemNameFuture.join()
+ MinecraftForge.EVENT_BUS.post(DataLoaded(this))
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/EyedropsDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/EyedropsDetection.kt
new file mode 100644
index 0000000..04dbe80
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/EyedropsDetection.kt
@@ -0,0 +1,35 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class EyedropsDetection {
+
+ val capsaicinEyedropsUsed = "You applied the eyedrops on the minion and ran out!".toPattern()
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ capsaicinEyedropsUsed.useMatcher(event.message) {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.CAPSAICIN_EYEDROPS_USED,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(ItemIds.CAPSAICIN_EYEDROPS_NO_CHARGES, 1)
+ )
+ )
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/ForgeDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/ForgeDetection.kt
new file mode 100644
index 0000000..95811ed
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/ForgeDetection.kt
@@ -0,0 +1,48 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.GuiClickEvent
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.matches
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+
+class ForgeDetection {
+ val furnaceSlot = 9 + 4
+ val furnaceName = "Forge Slot.*".toPattern()
+
+ @SubscribeEvent
+ fun onClick(event: GuiClickEvent) {
+ val slot = event.slotIn ?: return
+ val clickedItem = slot.stack ?: return
+ if (clickedItem.displayName.unformattedString() != "Confirm") return
+ val furnaceSlotName = slot.inventory.getStackInSlot(furnaceSlot)?.displayName?.unformattedString() ?: return
+ if (!furnaceName.matches(furnaceSlotName))
+ return
+ val cl = (0 until slot.inventory.sizeInventory - 9)
+ .mapNotNull {
+ val stack = slot.inventory.getStackInSlot(it) ?: return@mapNotNull null
+ val x = it % 9
+ if (x == 4) return@mapNotNull null
+ ItemChange(
+ stack.getInternalId() ?: return@mapNotNull null,
+ stack.stackSize.toDouble(),
+ if (x < 4) ItemChange.ChangeDirection.LOST else ItemChange.ChangeDirection.GAINED
+ )
+ }
+ logger.logEntry(LedgerEntry(
+ TransactionType.FORGED,
+ Instant.now(),
+ cl,
+ ))
+ }
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/GambleDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/GambleDetection.kt
new file mode 100644
index 0000000..a8f79c1
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/GambleDetection.kt
@@ -0,0 +1,62 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class GambleDetection {
+
+ val dieRolled =
+ "Your (?<isHighClass>High Class )?Archfiend Dice rolled a (?<face>[1-7])!.*"
+ .toPattern()
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ dieRolled.useMatcher(event.message) {
+ val isLowClass = group("isHighClass").isNullOrBlank()
+ val item = if (isLowClass) ItemIds.ARCHFIEND_DICE else ItemIds.HIGH_CLASS_ARCHFIEND_DICE
+ val face = group("face")
+ val rollCost = if (isLowClass) 666_000.0 else 6_600_000.0
+ if (face == "7") {
+ logger.logEntry(LedgerEntry(
+ TransactionType.DIE_ROLLED,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(item, 1),
+ ItemChange.loseCoins(rollCost),
+ ItemChange.gain(ItemIds.DYE_ARCHFIEND, 1),
+ )
+ ))
+ } else if (face == "6") {
+ logger.logEntry(LedgerEntry(
+ TransactionType.DIE_ROLLED,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(item, 1),
+ ItemChange.loseCoins(rollCost),
+ ItemChange.gainCoins(if (isLowClass) 15_000_000.0 else 100_000_000.0),
+ )
+ ))
+ } else {
+ logger.logEntry(LedgerEntry(
+ TransactionType.DIE_ROLLED,
+ event.timestamp,
+ listOf(
+ ItemChange(item, 1.0, ItemChange.ChangeDirection.CATALYST),
+ ItemChange.loseCoins(rollCost),
+ )
+ ))
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionDetection.kt
new file mode 100644
index 0000000..ae86519
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionDetection.kt
@@ -0,0 +1,35 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.gen.ItemIds
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class GodPotionDetection {
+
+ val godPotionDrank = "(SIP|SLURP|GULP|CHUGALUG)! The God Potion grants you powers for .*!".toPattern()
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ godPotionDrank.useMatcher(event.message) {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.GOD_POTION_DRANK,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(ItemIds.GOD_POTION_2, 1)
+ )
+ )
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionMixinDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionMixinDetection.kt
new file mode 100644
index 0000000..b96a24a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/GodPotionMixinDetection.kt
@@ -0,0 +1,38 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class GodPotionMixinDetection {
+
+ val godPotionMixinDrank = "SCHLURP! The (effects of the )?(?<what>.*?) (grants you effects|have been extended by) .*! They will pause if your God Potion expires.".toPattern()
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @Inject
+ lateinit var itemIdProvider: ItemIdProvider
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ godPotionMixinDrank.useMatcher(event.message) {
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.GOD_POTION_MIXIN_DRANK,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(itemIdProvider.findForName(group("what")) ?: ItemId.NIL, 1)
+ )
+ )
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/KatDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/KatDetection.kt
new file mode 100644
index 0000000..eda5aba
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/KatDetection.kt
@@ -0,0 +1,95 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.BeforeGuiAction
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.getLore
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+
+class KatDetection {
+ @Inject
+ lateinit var log: LedgerLogger
+
+ @Inject
+ lateinit var itemIdProvider: ItemIdProvider
+
+ val giftNameToIdMap = mapOf(
+ "flower" to ItemId("KAT_FLOWER"),
+ "bouquet" to ItemId("KAT_BOUQUET"),
+ )
+ val katGift = "\\[NPC\\] Kat: A (?<gift>.*)\\? For me\\? How sweet!".toPattern()
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ katGift.useMatcher(event.message) {
+ val giftName = group("gift")
+ val giftId = giftNameToIdMap[giftName]
+ log.logEntry(LedgerEntry(
+ TransactionType.KAT_TIMESKIP,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(giftId ?: ItemId.NIL, 1)
+ )
+ ))
+ }
+ }
+
+ val confirmSlot = 9 + 9 + 4
+ val petSlot = 9 + 4
+
+ data class PetUpgrade(
+ val beforePetId: ItemId,
+ val cost: List<Pair<ItemId, Double>>
+ )
+
+ var lastPetUpgradeScheduled: PetUpgrade? = null
+
+ @SubscribeEvent
+ fun onClick(event: BeforeGuiAction) {
+ val slots = event.chestSlots ?: return
+ val petItem = slots.lowerChestInventory.getStackInSlot(petSlot) ?: return
+ val beforePetId = petItem.getInternalId() ?: return
+ val confirmItem = slots.lowerChestInventory.getStackInSlot(confirmSlot) ?: return
+ val lore = confirmItem.getLore()
+ val cost = itemIdProvider.findCostItemsFromSpan(lore)
+ lastPetUpgradeScheduled = PetUpgrade(beforePetId, cost)
+ }
+
+ val petUpgradeDialogue = "\\[NPC\\] Kat: I'll get your (?<type>.*) upgraded to (?<tier>.*) in no time!".toPattern()
+ fun upgradePetTier(itemId: ItemId): ItemId {
+ val str = itemId.string.split(";", limit = 2)
+ if (str.size == 2) {
+ val (type, tier) = str
+ val tierT = tier.toIntOrNull()
+ if (tierT != null)
+ return ItemId(type + ";" + (tierT + 1))
+ }
+ return itemId
+ }
+
+ @SubscribeEvent
+ fun onPetUpgrade(event: ChatReceived) {
+ petUpgradeDialogue.useMatcher(event.message) {
+ val upgrade = lastPetUpgradeScheduled ?: return
+ lastPetUpgradeScheduled = null
+ log.logEntry(LedgerEntry(
+ TransactionType.KAT_UPGRADE,
+ event.timestamp,
+ listOf(
+ ItemChange.lose(upgrade.beforePetId, 1),
+ ItemChange.gain(upgradePetTier(upgrade.beforePetId), 1),
+ ) + upgrade.cost.map { ItemChange.lose(it.first, it.second) },
+ ))
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/KuudraChestDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/KuudraChestDetection.kt
new file mode 100644
index 0000000..e0e9322
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/KuudraChestDetection.kt
@@ -0,0 +1,45 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.GuiClickEvent
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.client.Minecraft
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+class KuudraChestDetection : ChestDetection() {
+ // TODO: extra essence for kuudra pet (how?), item SALVAGE detection
+ // TODO: save uuid along side item id
+
+ val kuudraKeyPattern = "KUUDRA_.*_TIER_KEY".toPattern()
+
+ @Inject
+ lateinit var log: LedgerLogger
+
+ @Inject
+ lateinit var minecraft: Minecraft
+ fun hasKey(keyItem: ItemId): Boolean {
+ val p = minecraft.thePlayer ?: return false
+ return p.inventory.mainInventory.any { it?.getInternalId() == keyItem }
+ }
+
+ @SubscribeEvent
+ fun onRewardChestClick(event: GuiClickEvent) {
+ val diffs = scrapeChestReward(event.slotIn ?: return) ?: return
+ val requiredKey = diffs.diff.find {
+ it.direction == ItemChange.ChangeDirection.LOST && kuudraKeyPattern.asPredicate().test(it.itemId.string)
+ }?.itemId
+ if (requiredKey != null && !hasKey(requiredKey)) {
+ return
+ }
+ log.logEntry(LedgerEntry(
+ TransactionType.KUUDRA_CHEST_OPEN,
+ diffs.timestamp,
+ diffs.diff,
+ ))
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/MineshaftCorpseDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/MineshaftCorpseDetection.kt
new file mode 100644
index 0000000..60b06ae
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/MineshaftCorpseDetection.kt
@@ -0,0 +1,81 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.matches
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.BorderedTextTracker
+import moe.nea.ledger.utils.di.Inject
+
+class MineshaftCorpseDetection : BorderedTextTracker() {
+ /*
+[23:39:47] [Client thread/INFO]: [CHAT] §r§a§l▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§b§l§r§9§lLAPIS §r§b§lCORPSE LOOT! §r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§a§lREWARDS§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§5+100 HOTM Experience§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§a§r§aGreen Goblin Egg§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§9Enchanted Glacite §r§8x2§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§9☠ Fine Onyx Gemstone§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§a☠ Flawed Onyx Gemstone §r§8x20§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§a☘ Flawed Peridot Gemstone §r§8x40§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r §r§bGlacite Powder §r§8x500§r
+[23:39:47] [Client thread/INFO]: [CHAT] §e[SkyHanni] Profit for §9Lapis Corpse§e: §678k§r
+[23:39:47] [Client thread/INFO]: [CHAT] §r§a§l▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬§r
+ */
+
+ val corpseEnterMessage = " (?<corpseKind>.*) CORPSE LOOT!".toPattern()
+
+ override fun shouldEnter(event: ChatReceived): Boolean {
+ return corpseEnterMessage.matches(event.message)
+ }
+
+ override fun shouldExit(event: ChatReceived): Boolean {
+ return genericBorderExit.matches(event.message)
+ }
+
+ override fun onBorderedTextFinished(enclosed: List<ChatReceived>) {
+ val rewards = enclosed.asSequence()
+ .dropWhile { it.message != " REWARDS" }
+ .drop(1)
+ .mapNotNull {
+ itemIdProvider.findStackableItemByName(it.message, true)
+ }
+ .map { ItemChange.unpairGain(it) }
+ .toMutableList()
+ val introMessage = enclosed.first()
+ val corpseTyp = corpseEnterMessage.useMatcher(introMessage.message) {
+ group("corpseKind")
+ }!!
+ val keyTyp = corpseNameToKey[corpseTyp]
+ if (keyTyp == null) {
+ errorUtil.reportAdHoc("Unknown corpse type $corpseTyp")
+ } else if (keyTyp != ItemId.NIL) {
+ rewards.add(ItemChange.lose(keyTyp, 1))
+ }
+ logger.logEntry(
+ LedgerEntry(
+ TransactionType.CORPSE_DESECRATED,
+ introMessage.timestamp,
+ rewards
+ )
+ )
+ }
+
+ val corpseNameToKey = mapOf(
+ "LAPIS" to ItemId.NIL,
+ "VANGUARD" to ItemId("SKELETON_KEY"),
+ "UMBER" to ItemId("UMBER_KEY"),
+ "TUNGSTEN" to ItemId("TUNGSTEN_KEY"),
+ )
+
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @Inject
+ lateinit var itemIdProvider: ItemIdProvider
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/MinionDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/MinionDetection.kt
new file mode 100644
index 0000000..6999c7f
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/MinionDetection.kt
@@ -0,0 +1,61 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ExpiringValue
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.ROMAN_NUMBER_PATTERN
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.BeforeGuiAction
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.parseRomanNumber
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraft.client.gui.inventory.GuiChest
+import net.minecraft.inventory.ContainerChest
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+import kotlin.time.Duration.Companion.seconds
+
+class MinionDetection @Inject constructor(val ledger: LedgerLogger) {
+ // §aYou received §r§6367,516.8 coins§r§a!
+ val hopperCollectPattern = "You received (?<amount>$SHORT_NUMBER_PATTERN) coins?!".toPattern()
+ val minionNamePattern = "(?<name>.*) Minion (?<level>$ROMAN_NUMBER_PATTERN)".toPattern()
+
+ var lastOpenedMinion = ExpiringValue.empty<ItemId>()
+
+ @SubscribeEvent
+ fun onBeforeClaim(event: BeforeGuiAction) {
+ val container = event.gui as? GuiChest ?: return
+ val inv = (container.inventorySlots as ContainerChest).lowerChestInventory
+ val invName = inv.displayName.unformattedText.unformattedString()
+ minionNamePattern.useMatcher(invName) {
+ val name = group("name")
+ val level = parseRomanNumber(group("level"))
+ lastOpenedMinion = ExpiringValue(
+ ItemId(name.uppercase().replace(" ", "_")
+ .replace("MINION", "GENERATOR") + "_" + level))
+ }
+ }
+
+
+ @SubscribeEvent
+ fun onChat(event: ChatReceived) {
+ hopperCollectPattern.useMatcher(event.message) {
+ val minionName = lastOpenedMinion.consume(3.seconds)
+ ledger.logEntry(LedgerEntry(
+ TransactionType.AUTOMERCHANT_PROFIT_COLLECT,
+ Instant.now(),
+ listOf(
+ ItemChange.gainCoins(parseShortNumber(group("amount"))),
+ ItemChange(minionName ?: ItemId.NIL, 1.0, ItemChange.ChangeDirection.CATALYST)
+ ),
+ ))
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/NpcDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/NpcDetection.kt
new file mode 100644
index 0000000..95b8aa5
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/NpcDetection.kt
@@ -0,0 +1,111 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.asIterable
+import moe.nea.ledger.events.BeforeGuiAction
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.events.ExtraSupplyIdEvent
+import moe.nea.ledger.getDisplayNameU
+import moe.nea.ledger.getInternalId
+import moe.nea.ledger.getLore
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.ErrorUtil
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.regex.Pattern
+
+class NpcDetection @Inject constructor(val ledger: LedgerLogger, val ids: ItemIdProvider) {
+
+ val npcBuyPattern =
+ Pattern.compile("You bought (back )?(?<what>.*?) (x(?<count>$SHORT_NUMBER_PATTERN) )?for (?<coins>$SHORT_NUMBER_PATTERN) Coins?!")
+ val npcSellPattern =
+ Pattern.compile("You sold (?<what>.*) (x(?<count>$SHORT_NUMBER_PATTERN) )?for (?<coins>$SHORT_NUMBER_PATTERN) Coins?!")
+
+ // You bought InfiniDirt™ Wand!
+ // You bought Prismapump x4!
+ val npcBuyWithItemPattern =
+ "You bought (?<what>.*?)!".toPattern()
+ var storedPurchases = mutableMapOf<String, List<ItemChange>>()
+
+ @SubscribeEvent
+ fun onClick(event: BeforeGuiAction) {
+ (event.chestSlots?.lowerChestInventory?.asIterable() ?: listOf())
+ .filterNotNull().forEach {
+ val name = it.getDisplayNameU().unformattedString()
+ val id = it.getInternalId() ?: return@forEach
+ val count = it.stackSize
+ val cost = ids.findCostItemsFromSpan(it.getLore())
+ storedPurchases[name] = listOf(ItemChange.gain(id, count)) + cost.map { ItemChange.unpairLose(it) }
+ }
+ }
+
+ @SubscribeEvent
+ fun addChocolate(event: ExtraSupplyIdEvent) {
+ event.store("Chocolate", ItemId("SKYBLOCK_CHOCOLATE"))
+ }
+
+ @Inject
+ lateinit var errorUtil: ErrorUtil
+
+ @SubscribeEvent
+ fun onBarteredItemBought(event: ChatReceived) {
+ npcBuyWithItemPattern.useMatcher(event.message) {
+ val changes = storedPurchases[group("what")]
+ if (changes == null) {
+ errorUtil.reportAdHoc("Item bought for items without associated cost")
+ }
+ storedPurchases.clear()
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.NPC_BUY,
+ event.timestamp,
+ changes ?: listOf()
+ )
+ )
+ }
+ }
+
+ @SubscribeEvent
+ fun onNpcBuy(event: ChatReceived) {
+ npcBuyPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.NPC_BUY,
+ event.timestamp,
+ listOf(
+ ItemChange.loseCoins(
+ parseShortNumber(group("coins")),
+ ),
+ ItemChange.gain(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ group("count")?.let(::parseShortNumber) ?: 1,
+ )
+ )
+ )
+ )
+ }
+ npcSellPattern.useMatcher(event.message) {
+ ledger.logEntry(
+ LedgerEntry(
+ TransactionType.NPC_SELL,
+ event.timestamp,
+ listOf(
+ ItemChange.gainCoins(parseShortNumber(group("coins"))),
+ ItemChange.lose(
+ ids.findForName(group("what")) ?: ItemId.NIL,
+ group("count")?.let(::parseShortNumber)?.toInt() ?: 1,
+ )
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/UpdateChecker.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/UpdateChecker.kt
new file mode 100644
index 0000000..0d89ca1
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/UpdateChecker.kt
@@ -0,0 +1,167 @@
+package moe.nea.ledger.modules
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+import moe.nea.ledger.DevUtil
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.TriggerCommand
+import moe.nea.ledger.config.LedgerConfig
+import moe.nea.ledger.config.MainOptions
+import moe.nea.ledger.events.RegistrationFinishedEvent
+import moe.nea.ledger.events.TriggerEvent
+import moe.nea.ledger.gen.BuildConfig
+import moe.nea.ledger.utils.ErrorUtil
+import moe.nea.ledger.utils.MinecraftExecutor
+import moe.nea.ledger.utils.di.Inject
+import moe.nea.ledger.utils.network.RequestUtil
+import moe.nea.libautoupdate.CurrentVersion
+import moe.nea.libautoupdate.GithubReleaseUpdateData
+import moe.nea.libautoupdate.GithubReleaseUpdateSource
+import moe.nea.libautoupdate.PotentialUpdate
+import moe.nea.libautoupdate.UpdateContext
+import moe.nea.libautoupdate.UpdateData
+import moe.nea.libautoupdate.UpdateTarget
+import moe.nea.libautoupdate.UpdateUtils
+import net.minecraft.util.ChatComponentText
+import net.minecraft.util.ChatStyle
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.util.concurrent.CompletableFuture
+
+class UpdateChecker @Inject constructor(
+ val errorUtil: ErrorUtil,
+ val requestUtil: RequestUtil,
+) {
+
+ @Inject
+ lateinit var minecraftExecutor: MinecraftExecutor
+
+ val updater = UpdateContext(
+ NightlyAwareGithubUpdateSource("nea89o", "LocalTransactionLedger"),
+ if (DevUtil.isDevEnv) UpdateTarget { listOf() }
+ else UpdateTarget.deleteAndSaveInTheSameFolder(UpdateChecker::class.java),
+ object : CurrentVersion {
+ override fun display(): String {
+ return BuildConfig.VERSION
+ }
+
+ override fun isOlderThan(element: JsonElement?): Boolean {
+ if (element !is JsonPrimitive || !element.isString) return true
+ val newHash = element.asString // TODO: change once i support non nightly update streams
+ val length = minOf(newHash.length, BuildConfig.GIT_COMMIT.length)
+ if (newHash.substring(0, length).equals(BuildConfig.GIT_COMMIT.substring(0, length), ignoreCase = true))
+ return false
+ return true
+ }
+
+
+ override fun toString(): String {
+ return "{gitversion:${BuildConfig.GIT_COMMIT}, version:${BuildConfig.FULL_VERSION}}"
+ }
+ },
+ "ledger"
+ )
+
+ class NightlyAwareGithubUpdateSource(owner: String, repository: String) :
+ GithubReleaseUpdateSource(owner, repository) {
+ override fun selectUpdate(updateStream: String, releases: List<GithubRelease>): UpdateData? {
+ if (updateStream == "nightly") {
+ return findAsset(releases.find { it.tagName == "nightly" })
+ }
+ return super.selectUpdate(updateStream, releases.filter { it.tagName != "nightly" })
+ }
+
+ val releaseRegex = "commit: `(?<hash>[a-f0-9]+)`".toPattern()
+
+ override fun findAsset(release: GithubRelease?): UpdateData? {
+ val update = super.findAsset(release) as GithubReleaseUpdateData? ?: return null
+ return GithubReleaseUpdateData(
+ update.versionName,
+ releaseRegex.matcher(update.releaseDescription)
+ .takeIf { it.find() }
+ ?.run { group("hash") }
+ ?.let(::JsonPrimitive)
+ ?: update.versionNumber,
+ update.sha256,
+ update.download,
+ update.releaseDescription,
+ update.targetCommittish,
+ update.createdAt,
+ update.publishedAt,
+ update.htmlUrl
+ )
+ }
+ }
+
+ init {
+ UpdateUtils.patchConnection {
+ this.requestUtil.enhanceConnection(it)
+ }
+ }
+
+ var latestUpdate: PotentialUpdate? = null
+ var hasNotified = false
+
+ @SubscribeEvent
+ fun onStartup(event: RegistrationFinishedEvent) {
+ if (config.main.updateCheck == MainOptions.UpdateCheckBehaviour.NONE) return
+ launchUpdateCheck()
+ }
+
+ fun launchUpdateCheck() {
+ errorUtil.listenToFuture(
+ updater.checkUpdate("nightly")
+ .thenAcceptAsync(
+ {
+ latestUpdate = it
+ informAboutUpdates(it)
+ }, minecraftExecutor)
+ )
+ }
+
+ @Inject
+ lateinit var config: LedgerConfig
+
+ @Inject
+ lateinit var triggerCommand: TriggerCommand
+
+ val installTrigger = "execute-download"
+
+ @Inject
+ lateinit var logger: LedgerLogger
+ fun informAboutUpdates(potentialUpdate: PotentialUpdate) {
+ if (hasNotified) return
+ hasNotified = true
+ if (!potentialUpdate.isUpdateAvailable) return
+ logger.printOut(
+ ChatComponentText("§aThere is a new update for LocalTransactionLedger. Click here to automatically download and install it.")
+ .setChatStyle(ChatStyle().setChatClickEvent(triggerCommand.getTriggerCommandLine(installTrigger))))
+ if (config.main.updateCheck == MainOptions.UpdateCheckBehaviour.FULL) {
+ downloadUpdate()
+ }
+ }
+
+ var updateFuture: CompletableFuture<Void>? = null
+
+ fun downloadUpdate() {
+ val l = latestUpdate ?: return
+ if (updateFuture != null) return
+ // TODO: inject into findAsset to overwrite the tag id with the commit id
+ logger.printOut("§aTrying to download ledger update ${l.update.versionName}")
+ updateFuture =
+ latestUpdate?.launchUpdate()
+ ?.thenAcceptAsync(
+ {
+ logger.printOut("§aLedger update downloaded. It will automatically apply after your next restart.")
+ }, minecraftExecutor)
+ ?.let(errorUtil::listenToFuture)
+ }
+
+ @SubscribeEvent
+ fun onTrigger(event: TriggerEvent) {
+ if (event.action == installTrigger) {
+ event.isCanceled = true
+ downloadUpdate()
+ }
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/modules/VisitorDetection.kt b/mod/src/main/kotlin/moe/nea/ledger/modules/VisitorDetection.kt
new file mode 100644
index 0000000..f457ae4
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/modules/VisitorDetection.kt
@@ -0,0 +1,87 @@
+package moe.nea.ledger.modules
+
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.ItemIdProvider
+import moe.nea.ledger.LedgerEntry
+import moe.nea.ledger.LedgerLogger
+import moe.nea.ledger.SHORT_NUMBER_PATTERN
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.events.ExtraSupplyIdEvent
+import moe.nea.ledger.events.GuiClickEvent
+import moe.nea.ledger.getDisplayNameU
+import moe.nea.ledger.getLore
+import moe.nea.ledger.parseShortNumber
+import moe.nea.ledger.unformattedString
+import moe.nea.ledger.useMatcher
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+import java.time.Instant
+
+class VisitorDetection {
+ @Inject
+ lateinit var logger: LedgerLogger
+
+ @Inject
+ lateinit var idProvider: ItemIdProvider
+
+ @SubscribeEvent
+ fun parseFromItem(event: GuiClickEvent) {
+ val stack = event.slotIn?.stack ?: return
+
+ val displayName = stack.getDisplayNameU()
+ if (displayName != "§aAccept Offer") return
+ val lore = stack.getLore()
+ if (!lore.contains("§eClick to give!")) return
+
+ val rewards = lore
+ .asSequence()
+ .dropWhile { it != "§7Rewards:" }.drop(1)
+ .takeWhile { it != "" }
+ .mapNotNull { parseGardenLoreLine(it) }
+ .map { ItemChange.gain(it.first, it.second) }
+ .toList()
+
+ val cost = lore
+ .asSequence()
+ .dropWhile { it != "§7Items Required:" }.drop(1)
+ .takeWhile { it != "" }
+ .mapNotNull { parseGardenLoreLine(it) }
+ .map { ItemChange.lose(it.first, it.second) }
+ .toList()
+
+ logger.logEntry(LedgerEntry(
+ TransactionType.VISITOR_BARGAIN,
+ Instant.now(),
+ cost + rewards
+ ))
+ }
+
+ private fun parseGardenLoreLine(rewardLine: String): Pair<ItemId, Double>? {
+ val f = rewardLine.unformattedString().trim()
+ return idProvider.findStackableItemByName(f, true)
+ }
+
+
+ @SubscribeEvent
+ fun supplyNames(event: ExtraSupplyIdEvent) {
+ event.store("Carrot", ItemId("CARROT_ITEM"))
+ event.store("Potato", ItemId("POTATO_ITEM"))
+ event.store("Jacob's Ticket", ItemId("JACOBS_TICKET"))
+ event.store("Cocoa Beans", ItemId("INK_SACK:3"))
+ event.store("Enchanted Cocoa Beans", ItemId("ENCHANTED_COCOA"))
+ event.store("Enchanted Red Mushroom Block", ItemId("ENCHANTED_HUGE_MUSHROOM_2"))
+ event.store("Enchanted Brown Mushroom Block", ItemId("ENCHANTED_HUGE_MUSHROOM_1"))
+ event.store("Nether Wart", ItemId("NETHER_STALK"))
+ event.store("Enchanted Nether Wart", ItemId("ENCHANTED_NETHER_STALK"))
+ event.store("Mutant Nether Wart", ItemId("MUTANT_NETHER_STALK"))
+ event.store("Jack o' Lantern", ItemId("JACK_O_LANTERN"))
+ event.store("Cactus Green", ItemId("INK_SACK:2"))
+ event.store("Hay Bale", ItemId("HAY_BLOCK"))
+ event.store("Rabbit's Foot", ItemId("RABBIT_FOOT"))
+ event.store("Raw Porkchop", ItemId("PORK"))
+ event.store("Raw Rabbit", ItemId("RABBIT"))
+ event.store("White Wool", ItemId("WOOL"))
+ event.store("Copper Dye", ItemId("DYE_COPPER"))
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/BorderedTextTracker.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/BorderedTextTracker.kt
new file mode 100644
index 0000000..9e621e8
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/BorderedTextTracker.kt
@@ -0,0 +1,41 @@
+package moe.nea.ledger.utils
+
+import moe.nea.ledger.events.ChatReceived
+import moe.nea.ledger.utils.di.Inject
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent
+
+abstract class BorderedTextTracker {
+
+ val genericBorderExit = "▬{10,}".toPattern()
+
+ @Inject
+ lateinit var errorUtil: ErrorUtil
+ var stack: MutableList<ChatReceived>? = null
+
+
+ @SubscribeEvent
+ fun receiveText(event: ChatReceived) {
+ if (stack != null && shouldExit(event)) {
+ exit()
+ return
+ }
+ if (shouldEnter(event)) {
+ if (stack != null) {
+ errorUtil.reportAdHoc("Double entered a bordered message")
+ exit()
+ }
+ stack = mutableListOf()
+ }
+ stack?.add(event)
+ }
+
+ private fun exit() {
+ onBorderedTextFinished(stack!!)
+ stack = null
+ }
+
+ abstract fun shouldEnter(event: ChatReceived): Boolean
+ abstract fun shouldExit(event: ChatReceived): Boolean
+ abstract fun onBorderedTextFinished(enclosed: List<ChatReceived>)
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/ErrorUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/ErrorUtil.kt
new file mode 100644
index 0000000..e0c83f9
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/ErrorUtil.kt
@@ -0,0 +1,52 @@
+package moe.nea.ledger.utils
+
+import moe.nea.ledger.utils.di.Inject
+import moe.nea.ledger.utils.telemetry.ContextValue
+import moe.nea.ledger.utils.telemetry.EventRecorder
+import moe.nea.ledger.utils.telemetry.Span
+import java.util.concurrent.CompletableFuture
+
+class ErrorUtil {
+
+ @Inject
+ lateinit var reporter: EventRecorder
+
+ fun reportAdHoc(message: String) {
+ report(Exception(message), message)
+
+ }
+
+ fun report(exception: Throwable, message: String?) {
+ Span.current().recordException(reporter, exception, message)
+ }
+
+ fun <T> Result<T>.getOrReport(): T? {
+ val exc = exceptionOrNull()
+ if (exc != null) {
+ report(exc, null)
+ }
+ return getOrNull()
+ }
+
+ fun <T : CompletableFuture<*>> listenToFuture(t: T): T {
+ t.handle { ignored, exception ->
+ if (exception != null)
+ report(exception, "Uncaught exception in completable future")
+ }
+ return t
+ }
+
+ inline fun <T> catch(
+ vararg pairs: Pair<String, ContextValue>,
+ crossinline function: () -> T
+ ): T? {
+ return Span.current().enterWith(*pairs) {
+ try {
+ return@enterWith function()
+ } catch (ex: Exception) {
+ report(ex, null)
+ return@enterWith null
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/GsonUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/GsonUtil.kt
new file mode 100644
index 0000000..d3c1f6e
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/GsonUtil.kt
@@ -0,0 +1,10 @@
+package moe.nea.ledger.utils
+
+import com.google.gson.reflect.TypeToken
+
+object GsonUtil {
+ inline fun <reified T> typeToken(): TypeToken<T> {
+ return object : TypeToken<T>() {}
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/MinecraftExecutor.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/MinecraftExecutor.kt
new file mode 100644
index 0000000..affd86c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/MinecraftExecutor.kt
@@ -0,0 +1,10 @@
+package moe.nea.ledger.utils
+
+import net.minecraft.client.Minecraft
+import java.util.concurrent.Executor
+
+class MinecraftExecutor : Executor {
+ override fun execute(command: Runnable) {
+ Minecraft.getMinecraft().addScheduledTask(command)
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/NoSideEffects.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/NoSideEffects.kt
new file mode 100644
index 0000000..9b0e7a3
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/NoSideEffects.kt
@@ -0,0 +1,4 @@
+package moe.nea.ledger.utils
+
+@Retention(AnnotationRetention.BINARY)
+annotation class NoSideEffects
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/network/Request.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/network/Request.kt
new file mode 100644
index 0000000..ddf2fcc
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/network/Request.kt
@@ -0,0 +1,40 @@
+package moe.nea.ledger.utils.network
+
+import com.google.gson.JsonElement
+import java.net.URL
+
+data class Request(
+ val url: URL,
+ val method: Method,
+ val body: String?,
+ val headers: Map<String, String>,
+) {
+ enum class Method {
+ GET, POST
+ }
+
+ enum class MediaType(val text: String) {
+ JSON("application/json"),
+ TEXT("text/plain"),
+ HTML("text/html"),
+ ANY("*/*"),
+ }
+
+ fun withHeaders(map: Map<String, String>): Request {
+ // TODO: enforce caselessness?
+ return this.copy(headers = headers + map)
+ }
+
+ fun post() = copy(method = Method.POST)
+ fun get() = copy(method = Method.GET)
+
+ fun json(element: JsonElement) = copy(
+ headers = headers + mapOf("content-type" to "application/json"),
+ body = element.toString())
+
+ fun accept(request: MediaType) = withHeaders(mapOf("accept" to request.text))
+
+ fun acceptJson() = accept(MediaType.JSON)
+
+ fun execute(requestUtil: RequestUtil) = requestUtil.executeRequest(this)
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/network/RequestUtil.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/network/RequestUtil.kt
new file mode 100644
index 0000000..a49c65a
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/network/RequestUtil.kt
@@ -0,0 +1,63 @@
+package moe.nea.ledger.utils.network
+
+import moe.nea.ledger.utils.ErrorUtil
+import moe.nea.ledger.utils.di.Inject
+import java.net.URL
+import java.net.URLConnection
+import java.security.KeyStore
+import java.util.zip.GZIPInputStream
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
+
+class RequestUtil @Inject constructor(val errorUtil: ErrorUtil) {
+
+ private fun createSSLContext(): SSLContext? = errorUtil.catch {
+ val keyStorePath = RequestUtil::class.java.getResourceAsStream("/ledgerkeystore.jks")
+ ?: error("Could not locate keystore")
+ val keyStore = KeyStore.getInstance("JKS")
+ keyStore.load(keyStorePath, "neuneu".toCharArray())
+ val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
+ val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ kmf.init(keyStore, null)
+ tmf.init(keyStore)
+ val ctx = SSLContext.getInstance("TLS")
+ ctx.init(kmf.keyManagers, tmf.trustManagers, null)
+ return@catch ctx
+ }
+
+ val sslContext = createSSLContext()
+
+ fun enhanceConnection(connection: URLConnection) {
+ if (connection is HttpsURLConnection && sslContext != null) {
+ connection.sslSocketFactory = sslContext.socketFactory
+ }
+ }
+
+ fun createRequest(url: String) = createRequest(URL(url))
+ fun createRequest(url: URL) = Request(url, Request.Method.GET, null, mapOf())
+
+ fun executeRequest(request: Request): Response {
+ val connection = request.url.openConnection()
+ enhanceConnection(connection)
+ connection.setRequestProperty("accept-encoding", "gzip")
+ request.headers.forEach { (k, v) ->
+ connection.setRequestProperty(k, v)
+ }
+ if (request.body != null) {
+ connection.getOutputStream().write(request.body.encodeToByteArray())
+ connection.getOutputStream().close()
+ }
+ var stream = connection.getInputStream()
+ if (connection.contentEncoding == "gzip") {
+ stream = GZIPInputStream(stream)
+ }
+ val text = stream.bufferedReader().readText()
+ stream.close()
+ // Do NOT call connection.disconnect() to allow for connection reuse
+ return Response(request, text, connection.headerFields)
+ }
+
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/network/Response.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/network/Response.kt
new file mode 100644
index 0000000..daae7f7
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/network/Response.kt
@@ -0,0 +1,19 @@
+package moe.nea.ledger.utils.network
+
+import com.google.gson.reflect.TypeToken
+import moe.nea.ledger.Ledger
+
+data class Response(
+ val source: Request,
+ // TODO: allow other body processors, to avoid loading everything as strings
+ val response: String,
+ val headers: Map<String, List<String>>,
+) {
+ fun <T : Any> json(typ: TypeToken<T>): T {
+ return Ledger.gson.fromJson(response, typ.type)
+ }
+
+ fun <T : Any> json(clazz: Class<T>): T {
+ return Ledger.gson.fromJson(response, clazz)
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/BooleanContext.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/BooleanContext.kt
new file mode 100644
index 0000000..5f4ccdf
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/BooleanContext.kt
@@ -0,0 +1,10 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+
+class BooleanContext(val boolean: Boolean) : ContextValue {
+ override fun serialize(): JsonElement {
+ return JsonPrimitive(boolean)
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/CommonKeys.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/CommonKeys.kt
new file mode 100644
index 0000000..004ae9c
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/CommonKeys.kt
@@ -0,0 +1,9 @@
+package moe.nea.ledger.utils.telemetry
+
+object CommonKeys {
+ val EVENT_MESSAGE = "event_message"
+ val EXCEPTION = "event_exception"
+ val COMMIT_VERSION = "version_commit"
+ val VERSION = "version"
+ val PHASE = "phase" // TODO: add a sort of "manual" stacktrace with designated function phases
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Context.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Context.kt
new file mode 100644
index 0000000..3c30a52
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Context.kt
@@ -0,0 +1,57 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonObject
+
+class Context(val data: MutableMap<String, ContextValue> = mutableMapOf()) : ContextValue.Collatable<Context> {
+
+ inline fun <reified T : ContextValue> getT(key: String): T? {
+ return get(key) as? T
+ }
+
+ fun get(key: String): ContextValue? {
+ return data[key]
+ }
+
+ fun add(key: String, value: ContextValue) {
+ data[key] = value
+ }
+
+ @Suppress("NOTHING_TO_INLINE")
+ private inline fun <T : ContextValue.Collatable<T>> cope(
+ left: ContextValue.Collatable<T>,
+ right: ContextValue
+ ): ContextValue {
+ return try {
+ left.combineWith(right as T)
+ } catch (ex: Exception) {
+ // TODO: cope with this better
+ right
+ }
+ }
+
+ override fun combineWith(overrides: Context): Context {
+ val copy = data.toMutableMap()
+ for ((key, overrideValue) in overrides.data) {
+ copy.merge(key, overrideValue) { old, new ->
+ if (old is ContextValue.Collatable<*>) {
+ cope(old, new)
+ } else {
+ new
+ }
+ }
+ }
+ return Context(copy)
+ }
+
+ override fun actualize(): Context {
+ return this
+ }
+
+ override fun serialize(): JsonObject {
+ val obj = JsonObject()
+ data.forEach { (k, v) ->
+ obj.add(k, v.serialize())
+ }
+ return obj
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ContextValue.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ContextValue.kt
new file mode 100644
index 0000000..b5891fc
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ContextValue.kt
@@ -0,0 +1,70 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+
+interface ContextValue {
+ companion object {
+ fun <T : Collatable<T>> lazyCollatable(value: () -> Collatable<T>): Collatable<T> {
+ return LazyCollatable(value)
+ }
+
+ fun lazy(value: () -> ContextValue): ContextValue {
+ return object : ContextValue {
+ val value by kotlin.lazy(value)
+ override fun serialize(): JsonElement {
+ return this.value.serialize()
+ }
+ }
+ }
+
+ fun bool(boolean: Boolean): ContextValue {
+ return BooleanContext(boolean)
+ }
+
+ fun string(message: String): ContextValue {
+ return StringContext(message)
+ }
+
+ fun jsonObject(vararg pairs: Pair<String, JsonElement>): ContextValue {
+ val obj = JsonObject()
+ for ((l, r) in pairs) {
+ obj.add(l, r)
+ }
+ return JsonElementContext(obj)
+ }
+
+ fun compound(vararg pairs: Pair<String, String>): ContextValue {
+ val obj = JsonObject()
+ for ((l, r) in pairs) {
+ obj.addProperty(l, r)
+ }
+ // TODO: should this be its own class?
+ return JsonElementContext(obj)
+ }
+ }
+
+ // TODO: allow other serialization formats
+ fun serialize(): JsonElement
+ interface Collatable<T : Collatable<T>> : ContextValue {
+ fun combineWith(overrides: T): T
+ fun actualize(): T
+ }
+
+ private class LazyCollatable<T : Collatable<T>>(
+ provider: () -> Collatable<T>,
+ ) : Collatable<T> {
+ val value by kotlin.lazy(provider)
+ override fun actualize(): T {
+ return value.actualize()
+ }
+
+ override fun combineWith(overrides: T): T {
+ return value.combineWith(overrides)
+ }
+
+ override fun serialize(): JsonElement {
+ return value.serialize()
+ }
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/EventRecorder.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/EventRecorder.kt
new file mode 100644
index 0000000..28b1ab5
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/EventRecorder.kt
@@ -0,0 +1,9 @@
+package moe.nea.ledger.utils.telemetry
+
+interface EventRecorder {
+ companion object {
+ var instance: EventRecorder? = null
+ }
+
+ fun record(event: RecordedEvent)
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ExceptionContextValue.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ExceptionContextValue.kt
new file mode 100644
index 0000000..96b70ec
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/ExceptionContextValue.kt
@@ -0,0 +1,39 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+
+class ExceptionContextValue(val exception: Throwable) : ContextValue {
+ val stackTrace by lazy {
+ exception.stackTraceToString()
+ }
+
+ override fun serialize(): JsonElement {
+ val jsonObject = JsonObject()
+ jsonObject.addProperty("exception_stackTrace", stackTrace)
+ jsonObject.add("exception_structure", walkExceptions(exception, 6))
+ return jsonObject
+ }
+
+ private fun walkExceptions(exception: Throwable, searchDepth: Int): JsonElement {
+ val obj = JsonObject()
+ obj.addProperty("class", exception.javaClass.name)
+ obj.addProperty("message", exception.message)
+ // TODO: allow exceptions to implement an "extra info" interface
+ if (searchDepth > 0) {
+ val cause = exception.cause
+ if (cause != null && cause !== exception) {
+ obj.add("cause", walkExceptions(cause, searchDepth - 1))
+ }
+ val suppressions = JsonArray()
+ for (suppressedException in exception.suppressedExceptions) {
+ suppressions.add(walkExceptions(suppressedException, searchDepth - 1))
+ }
+ if (suppressions.size() > 0) {
+ obj.add("suppressions", suppressions)
+ }
+ }
+ return obj
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/JsonElementContext.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/JsonElementContext.kt
new file mode 100644
index 0000000..1601f56
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/JsonElementContext.kt
@@ -0,0 +1,9 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonElement
+
+class JsonElementContext(val element: JsonElement) : ContextValue {
+ override fun serialize(): JsonElement {
+ return element
+ }
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/LoggingEventRecorder.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/LoggingEventRecorder.kt
new file mode 100644
index 0000000..82a76ed
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/LoggingEventRecorder.kt
@@ -0,0 +1,25 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.GsonBuilder
+import org.apache.logging.log4j.Logger
+
+class LoggingEventRecorder(
+ val logger: Logger,
+ val logJson: Boolean
+) : EventRecorder {
+ companion object {
+ private val gson = GsonBuilder().setPrettyPrinting().create()
+ }
+
+ override fun record(event: RecordedEvent) {
+ val exc = event.context.getT<ExceptionContextValue>(CommonKeys.EXCEPTION)
+ var message = "Event Recorded: " + event.context.getT<StringContext>(CommonKeys.EVENT_MESSAGE)?.message
+ if (logJson) {
+ message += "\n" + gson.toJson(event.context.serialize())
+ }
+ if (exc != null)
+ logger.error(message, exc.exception)
+ else
+ logger.warn(message)
+ }
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/RecordedEvent.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/RecordedEvent.kt
new file mode 100644
index 0000000..346417d
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/RecordedEvent.kt
@@ -0,0 +1,5 @@
+package moe.nea.ledger.utils.telemetry
+
+class RecordedEvent(val context: Context) {
+
+}
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Severity.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Severity.kt
new file mode 100644
index 0000000..e9a3b79
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Severity.kt
@@ -0,0 +1,8 @@
+package moe.nea.ledger.utils.telemetry
+
+enum class Severity {
+ INFO,
+ WARN,
+ ERROR,
+ CRITICAL,
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Span.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Span.kt
new file mode 100644
index 0000000..0d680a9
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/Span.kt
@@ -0,0 +1,146 @@
+package moe.nea.ledger.utils.telemetry
+
+class Span(val parent: Span?) : AutoCloseable {
+ companion object {
+ private val _current = object : InheritableThreadLocal<Span>() {
+ override fun initialValue(): Span {
+ return Span(null)
+ }
+
+ override fun childValue(parentValue: Span?): Span {
+ return parentValue?.forkNewRoot() ?: initialValue()
+ }
+ }
+
+ fun current(): Span {
+ return _current.get()
+ }
+ }
+
+ private val data = Context()
+
+ // TODO : replace string key with a SpanKey<T> class
+ fun add(key: String, value: ContextValue) {
+ data.add(key, value)
+ }
+
+ /**
+ * Create a sub span, and [enter] it, with the given values.
+ */
+ fun <T> enterWith(vararg pairs: Pair<String, ContextValue>, block: Span.() -> T): T {
+ return enter().use { span ->
+ pairs.forEach { (k, value) ->
+ span.add(k, value)
+ }
+ block(span)
+ }
+ }
+
+ /**
+ * Create a sub span, to attach some additional context, without modifying the [current] at all.
+ */
+ fun forkWith(vararg pairs: Pair<String, ContextValue?>): Span {
+ val newSpan = fork()
+ for ((key, value) in pairs) {
+ if (value == null) continue
+ newSpan.add(key, value)
+ }
+ return newSpan
+ }
+
+ /**
+ * Create a sub span, to which additional context can be added. This context will receive updates from its parent,
+ * and will be set as the [current]. To return to the parent, either call [exit] on the child. Or use inside of a
+ * [use] block.
+ */
+ fun enter(): Span {
+ require(_current.get() == this)
+ return fork().enterSelf()
+ }
+
+ /**
+ * Force [enter] this span, without creating a subspan. This bypasses checks like parent / child being the [current].
+ */
+ fun enterSelf(): Span {
+ _current.set(this)
+ return this
+ }
+
+ /**
+ * Creates a temporary sub span, to which additional context can be added. This context will receive updates from
+ * its parent, but will not be set as the [current].
+ */
+ fun fork(): Span {
+ return Span(this)
+ }
+
+ /**
+ * Create a new root span, that will not receive any updates from the current span, but will have all the same
+ * context keys associated.
+ */
+ fun forkNewRoot(): Span {
+ val newRoot = Span(null)
+ newRoot.data.data.putAll(collectContext().data)
+ return newRoot
+ }
+
+ /**
+ * Collect the context, including all parent context
+ */
+ fun collectContext(): Context {
+ if (parent != null)
+ return data.combineWith(parent.collectContext())
+ return data
+ }
+
+ /**
+ * Exit an [entered][enter] span, returning back to the parent context, and discard any current keys.
+ */
+ fun exit() {
+ require(parent != null)
+ require(_current.get() == this)
+ _current.set(parent)
+ }
+
+ /**
+ * [AutoCloseable] implementation for [exit]
+ */
+ override fun close() {
+ return exit()
+ }
+
+ /**
+ * Record an empty event given the context. This indicates nothing except for "I was here".
+ * @see recordMessageEvent
+ * @see recordException
+ */
+ fun recordEmptyTrace(recorder: EventRecorder) {
+ recorder.record(RecordedEvent(collectContext()))
+ }
+
+ /**
+ * Record a message with the key `"event_message"` to the recorder
+ */
+ fun recordMessageEvent(
+ recorder: EventRecorder,
+ message: String
+ ) {
+ forkWith(CommonKeys.EVENT_MESSAGE to ContextValue.string(message))
+ .recordEmptyTrace(recorder)
+ }
+
+ /**
+ * Record an exception to the recorder
+ */
+ fun recordException(
+ recorder: EventRecorder,
+ exception: Throwable,
+ message: String? = null
+ ) {
+ forkWith(
+ CommonKeys.EVENT_MESSAGE to message?.let(ContextValue::string),
+ CommonKeys.EXCEPTION to ExceptionContextValue(exception),
+ ).recordEmptyTrace(recorder)
+ }
+
+} \ No newline at end of file
diff --git a/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/StringContext.kt b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/StringContext.kt
new file mode 100644
index 0000000..2d33075
--- /dev/null
+++ b/mod/src/main/kotlin/moe/nea/ledger/utils/telemetry/StringContext.kt
@@ -0,0 +1,11 @@
+package moe.nea.ledger.utils.telemetry
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+
+class StringContext(val message: String) : ContextValue {
+ override fun serialize(): JsonElement {
+ return JsonPrimitive(message)
+ }
+
+}
diff --git a/mod/src/main/resources/ledgerkeystore.jks b/mod/src/main/resources/ledgerkeystore.jks
new file mode 100644
index 0000000..b71185a
--- /dev/null
+++ b/mod/src/main/resources/ledgerkeystore.jks
Binary files differ
diff --git a/mod/src/main/resources/mcmod.info b/mod/src/main/resources/mcmod.info
new file mode 100644
index 0000000..fdeffd8
--- /dev/null
+++ b/mod/src/main/resources/mcmod.info
@@ -0,0 +1,18 @@
+[
+ {
+ "modid": "${modid}",
+ "name": "Simple Ledger Mod",
+ "description": "Export all your activities into a file",
+ "version": "${version}",
+ "mcversion": "${mcversion}",
+ "url": "https://github.com/nea89o/LocalTransactionLedger/",
+ "updateUrl": "",
+ "authorList": [
+ "nea89"
+ ],
+ "credits": "",
+ "logoFile": "",
+ "screenshots": [],
+ "dependencies": []
+ }
+] \ No newline at end of file
diff --git a/mod/src/main/resources/mixins.moneyledger.json b/mod/src/main/resources/mixins.moneyledger.json
new file mode 100644
index 0000000..fa6482e
--- /dev/null
+++ b/mod/src/main/resources/mixins.moneyledger.json
@@ -0,0 +1,7 @@
+{
+ "package": "${basePackage}.mixin",
+ "plugin": "${basePackage}.init.AutoDiscoveryMixinPlugin",
+ "minVersion": "0.7",
+ "compatibilityLevel": "JAVA_8",
+ "__comment": "You do not need to manually register mixins in this template. Check the auto discovery mixin plugin for more info."
+}
diff --git a/mod/src/test/kotlin/moe/nea/ledger/NumberUtilKtTest.kt b/mod/src/test/kotlin/moe/nea/ledger/NumberUtilKtTest.kt
new file mode 100644
index 0000000..4068a42
--- /dev/null
+++ b/mod/src/test/kotlin/moe/nea/ledger/NumberUtilKtTest.kt
@@ -0,0 +1,17 @@
+package moe.nea.ledger
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class NumberUtilKtTest {
+ @Test
+ fun parseRomanNumberTest() {
+ assertEquals(4, parseRomanNumber("IV"))
+ assertEquals(1, parseRomanNumber("I"))
+ assertEquals(14, parseRomanNumber("XIV"))
+ assertEquals(3, parseRomanNumber("III"))
+ assertEquals(8, parseRomanNumber("IIX"))
+ assertEquals(500, parseRomanNumber("DM"))
+ assertEquals(2024, parseRomanNumber("MMXXIV"))
+ }
+} \ No newline at end of file