diff --git a/build.gradle b/build.gradle index 3e0018b..ea16101 100644 --- a/build.gradle +++ b/build.gradle @@ -36,8 +36,11 @@ dependencies { shadow libs.gson implementation libs.gson + include project(":versions:1.0.0-client") include project(":versions:1.0.1-server") + include project(":versions:1.1.0-client") include project(":versions:1.1.0-server") + include project(":versions:1.2.5-client") include project(":versions:1.2.5-server") include project(":versions:1.3.2") include project(":versions:1.4.7") diff --git a/gradle.properties b/gradle.properties index f73764f..9e1af52 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ mod_version = 1.1.0 maven_group = net.lostluma archives_base_name = server-stats -minecraft_version_min = 1.0.1 +minecraft_version_min = 1.0.0 minecraft_version_max = 1.12.2 # Minecraft Properties diff --git a/settings.gradle b/settings.gradle index fa50f91..8ac4399 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,8 +16,11 @@ pluginManagement { } } +include(":versions:1.0.0-client") include(":versions:1.0.1-server") +include(":versions:1.1.0-client") include(":versions:1.1.0-server") +include(":versions:1.2.5-client") include(":versions:1.2.5-server") include(":versions:1.3.2") include(":versions:1.4.7") diff --git a/versions/1.0.0-client/build.gradle b/versions/1.0.0-client/build.gradle new file mode 100644 index 0000000..163228f --- /dev/null +++ b/versions/1.0.0-client/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'maven-publish' + alias libs.plugins.shadow + alias libs.plugins.quilt.loom + alias libs.plugins.ploceus +} + +apply from: "${rootProject.projectDir}/gradle/common.gradle" + +group = project.maven_group +version = generateVersionWithMetadata() + +base { + archivesName = project.archives_base_name +} + +loom { + clientOnlyMinecraftJar() + + accessWidenerPath = file("src/main/resources/server_stats.accesswidener") +} + +ploceus { + clientOnlyMappings() +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + + mappings loom.layered { + mappings "net.ornithemc:feather:${project.feather_version}:v2" + addLayer ploceus.nestedMappings() // Required for nests + } + + nests "net.ornithemc:nests:${project.nests_version}" + modImplementation libs.quilt.loader + + implementation libs.gson +} + +shadowJar { + configurations = [project.configurations.shadow] + relocate "com.google", "net.lostluma.server_stats.external" +} + +remapJar { + inputFile.set shadowJar.archiveFile + dependsOn shadowJar +} diff --git a/versions/1.0.0-client/gradle.properties b/versions/1.0.0-client/gradle.properties new file mode 100644 index 0000000..8a16fa9 --- /dev/null +++ b/versions/1.0.0-client/gradle.properties @@ -0,0 +1,8 @@ +# Mod Properties +archives_base_name = server-stats-mixins-client + +# Minecraft Properties +minecraft_version = 1.0.0 + +nests_version = 1.0.0-client+build.1 +feather_version = 1.0.0-client+build.11 diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/Constants.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/Constants.java new file mode 100644 index 0000000..4e642c7 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/Constants.java @@ -0,0 +1,6 @@ +package net.lostluma.server_stats; + +public class Constants { + public static final String MOD_ID = "server_stats"; + public static final String STATS_PACKET_CHANNEL = MOD_ID + "|s"; +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java new file mode 100644 index 0000000..ee62753 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java @@ -0,0 +1,30 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.entity.living.player.InputPlayerEntity; +import net.minecraft.client.entity.living.player.LocalPlayerEntity; +import net.minecraft.entity.living.player.PlayerEntity; +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({ InputPlayerEntity.class, LocalPlayerEntity.class }) +public class LocalPlayerEntityMixin { + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Inject(method = "incrementStat(Lnet/minecraft/stat/Stat;I)V", at = @At("HEAD")) + private void incrementStat(net.minecraft.stat.Stat vanillaStat, int amount, CallbackInfo callbackInfo) { + if (vanillaStat == null) { + return; + } + + var stat = Stats.byVanillaId(vanillaStat.id); + + if (stat != null) { + this.getPlayer().server_stats$incrementStat(stat, amount); + } + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java new file mode 100644 index 0000000..85e87c4 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java @@ -0,0 +1,34 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.minecraft.world.WorldSettings; +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.CallbackInfo; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.living.player.InputPlayerEntity; + +@Mixin(Minecraft.class) +public class MinecraftMixin { + @Shadow + public InputPlayerEntity player; + + @Inject(method = "", at = @At("TAIL")) + private void onInit(CallbackInfo callbackInfo) { + Stats.init(); + } + + @Inject(method = "startGame", at = @At("HEAD")) + private void startGame(String worldDir, String worldName, WorldSettings worldSettings, CallbackInfo callbackInfo) { + ServerPlayerStats.setWorldDirectory(String.format("saves/%s", worldDir)); + } + + @Inject(method = "m_4977780", at = @At("TAIL")) + private void changeDimension(int dimension, CallbackInfo callbackInfo) { + this.player.server_stats$saveStats(); + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java new file mode 100644 index 0000000..49cff01 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java @@ -0,0 +1,18 @@ +package net.lostluma.server_stats.mixin.common; + +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; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.Entities; + +@Mixin(Entities.class) +public class EntitiesMixin { + @Inject(method = "register", at = @At("TAIL")) + private static void registerWithSpawnEgg(Class type, String key, int id, CallbackInfo callbackInfo) { + Stats.createEntityKillStat(key); + Stats.createKilledByEntityStat(key); + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java new file mode 100644 index 0000000..386add1 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java @@ -0,0 +1,75 @@ +package net.lostluma.server_stats.mixin.common; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.living.LivingEntity; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; +import net.lostluma.server_stats.types.StatsPlayer; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.living.player.PlayerEntity; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public class PlayerEntityMixin implements StatsPlayer { + @Unique + private ServerPlayerStats server_stats$serverPlayerStats = null; + + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Override + public void server_stats$incrementStat(Stat stat, int amount) { + var stats = this.server_stats$getStats(); + var player = (PlayerEntity)(Object)this; + + if (stats != null) { + stats.increment(player, stat, amount); + } + } + + @Override + public void server_stats$saveStats() { + var stats = this.server_stats$getStats(); + + if (stats != null) { + stats.save(); + } + } + + @Override + public @Nullable ServerPlayerStats server_stats$getStats() { + var player = (PlayerEntity)(Object)this; + + // Unmapped method returns true when the server is multiplayer + if (Minecraft.INSTANCE.m_2812472()) { + return null; + } + + if (this.server_stats$serverPlayerStats == null) { + this.server_stats$serverPlayerStats = new ServerPlayerStats(player); + } + + return this.server_stats$serverPlayerStats; + } + + @Inject(method = "onKill", at = @At("HEAD")) + private void onKill(LivingEntity entity, CallbackInfo callbackInfo) { + this.getPlayer().server_stats$incrementStat(Stats.getEntityKillStat(entity), 1); + } + + @Inject(method = "onKilled", at = @At("HEAD")) + private void onKilled(DamageSource source, CallbackInfo callbackInfo) { + if (source.getAttacker() != null) { + var attacker = source.getAttacker(); + this.getPlayer().server_stats$incrementStat(Stats.getKilledByEntityStat(attacker), 1); + } + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java new file mode 100644 index 0000000..f2ab2b9 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java @@ -0,0 +1,25 @@ +package net.lostluma.server_stats.mixin.common; + +import java.util.List; + +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.CallbackInfo; + +import net.minecraft.entity.living.player.PlayerEntity; +import net.minecraft.world.World; + +@Mixin(World.class) +public class WorldMixin { + @Shadow + public List players; + + @Inject(method = "saveData", at = @At("TAIL")) + public void onSave(CallbackInfo callbackInfo) { + for (PlayerEntity player : this.players) { + player.server_stats$saveStats(); + } + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java new file mode 100644 index 0000000..fed236a --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java @@ -0,0 +1,16 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +public class GeneralStat extends Stat { + public GeneralStat(String key, @Nullable Integer vanillaId) { + super(key, vanillaId); + } + + @Override + public Stat register() { + super.register(); + Stats.GENERAL.add(this); + return this; + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java new file mode 100644 index 0000000..9f92288 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java @@ -0,0 +1,143 @@ +package net.lostluma.server_stats.stats; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import net.minecraft.entity.living.player.PlayerEntity; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("squid:S2629") // Unconditional method call in log call +public class ServerPlayerStats { + private final String name; + protected final Map counters; + + private static @Nullable Path STATS = null; + + // Use Minecraft logger as it is already properly set up + private static final Logger LOGGER = Logger.getLogger("Minecraft"); + + private static final FileAttribute[] DEFAULT_ATTRIBUTES = getDefaultFileAttributes(); + + public ServerPlayerStats(PlayerEntity player) { + this.name = player.name; + this.counters = new ConcurrentHashMap<>(); + + this.load(); + } + + public static void setWorldDirectory(String worldDirName) { + STATS = Path.of(worldDirName).resolve("stats"); + } + + public void increment(PlayerEntity player, Stat stat, int amount) { + if (stat != null) { + this.set(player, stat, this.get(stat) + amount); + } + } + + public void set(PlayerEntity player, Stat stat, int value) { + this.counters.put(stat, value); + } + + public int get(Stat stat) { + return this.counters.getOrDefault(stat, 0); + } + + public void load() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + if (Files.exists(path)) { + try { + this.deserialize(); + } catch (IOException e) { + LOGGER.severe("Couldn't read statistics file " + path); + } catch (JsonParseException e) { + LOGGER.severe("Couldn't parse statistics file " + path); + } + } + } + + public void save() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + try { + Files.createDirectories(STATS); + + Path temp = Files.createTempFile(STATS, this.name, ".json", DEFAULT_ATTRIBUTES); + Files.writeString(temp, this.serialize(), StandardCharsets.UTF_8); + + Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE); // Prevent issues on server crash + } catch (IOException e) { + LOGGER.warning(String.format("Failed to write stats to %s! %s", path, e.toString())); + } + } + + public void deserialize() throws IOException { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + JsonElement root = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); + + if (!root.isJsonObject()) { + return; + } + + JsonObject data = root.getAsJsonObject(); + + for (Entry entry : data.entrySet()) { + JsonElement value = entry.getValue(); + Stat stat = Stats.byKey(entry.getKey()); + + if (stat != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + this.counters.put(stat, value.getAsInt()); + } else { + LOGGER.warning(String.format("Failed to read stat %s in %s! It's either unknown or has invalid data.", + entry.getKey(), path)); + } + } + } + + public String serialize() { + JsonObject result = new JsonObject(); + + for (Entry counter : this.counters.entrySet()) { + result.addProperty(counter.getKey().key, counter.getValue()); + } + + return result.toString(); + } + + private static FileAttribute[] getDefaultFileAttributes() { + if (!System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("linux")) { + return new FileAttribute[0]; + } + + return new FileAttribute[]{PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-r--r--"))}; + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java new file mode 100644 index 0000000..aa387a4 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java @@ -0,0 +1,47 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +/* + * A very cut-down version of the vanilla 1.7.10 Stat class. + */ +public class Stat { + public final String key; + + public final @Nullable Integer vanillaId; + + public Stat(String key, @Nullable Integer vanillaId) { + this.key = key; + this.vanillaId = vanillaId; + } + + public Stat register() { + if (Stats.BY_KEY.containsKey(this.key)) { + throw new RuntimeException("Duplicate stat id: \"" + ((Stat)Stats.BY_KEY.get(this.key)).key + "\" and \"" + this.key + "."); + } else { + Stats.ALL.add(this); + Stats.BY_KEY.put(this.key, this); + + if (this.vanillaId != null) { + Stats.BY_VANILLA_ID.put(this.vanillaId, this); + } + + return this; + } + } + + public boolean equals(Object object) { + if (this == object) { + return true; + } else if (object != null && this.getClass() == object.getClass()) { + Stat var2 = (Stat)object; + return this.key.equals(var2.key); + } else { + return false; + } + } + + public int hashCode() { + return this.key.hashCode(); + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java new file mode 100644 index 0000000..f52eaa5 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java @@ -0,0 +1,197 @@ +package net.lostluma.server_stats.stats; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import net.minecraft.block.Block; +import net.minecraft.crafting.CraftingManager; +import net.minecraft.crafting.recipe.CraftingRecipe; +import net.minecraft.entity.Entities; +import net.minecraft.entity.Entity; +import net.minecraft.item.BlockItem; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.smelting.SmeltingManager; + +/* + * A cut-down and simplified version of the vanilla 1.7.10 Stats class. + */ +@SuppressWarnings({ "squid:S3008", "squid:S2386" }) +public class Stats { + protected static Map BY_KEY = new HashMap<>(); + protected static Map BY_VANILLA_ID = new HashMap<>(); + + public static List ALL = new ArrayList<>(); + public static List GENERAL = new ArrayList<>(); + public static List USED = new ArrayList<>(); + public static List MINED = new ArrayList<>(); + + public static Stat GAMES_LEFT = new GeneralStat("stat.leaveGame", 1004).register(); + public static Stat MINUTES_PLAYED = new GeneralStat("stat.playOneMinute", 1100).register(); + public static Stat CM_WALKED = new GeneralStat("stat.walkOneCm", 2000).register(); + public static Stat CM_SWUM = new GeneralStat("stat.swimOneCm", 2001).register(); + public static Stat CM_FALLEN = new GeneralStat("stat.fallOneCm", 2002).register(); + public static Stat CM_CLIMB = new GeneralStat("stat.climbOneCm", 2003).register(); + public static Stat CM_FLOWN = new GeneralStat("stat.flyOneCm", 2004).register(); + public static Stat CM_DIVEN = new GeneralStat("stat.diveOneCm", 2005).register(); + public static Stat CM_MINECART = new GeneralStat("stat.minecartOneCm", 2006).register(); + public static Stat CM_SAILED = new GeneralStat("stat.boatOneCm", 2007).register(); + public static Stat CM_PIG = new GeneralStat("stat.pigOneCm", 2008).register(); + public static Stat JUMPS = new GeneralStat("stat.jump", 2010).register(); + public static Stat DROPS = new GeneralStat("stat.drop", 2011).register(); + public static Stat DAMAGE_DEALT = new GeneralStat("stat.damageDealt", 2020).register(); + public static Stat DAMAGE_TAKEN = new GeneralStat("stat.damageTaken", 2021).register(); + public static Stat DEATHS = new GeneralStat("stat.deaths", 2022).register(); + public static Stat MOBS_KILLED = new GeneralStat("stat.mobKills", 2023).register(); + public static Stat PLAYERS_KILLED = new GeneralStat("stat.playerKills", 2024).register(); + public static Stat FISH_CAUGHT = new GeneralStat("stat.fishCaught", 2025).register(); + + public static final Stat[] BLOCKS_MINED = new Stat[4096]; + public static final Stat[] ITEMS_CRAFTED = new Stat[32000]; + public static final Stat[] ITEMS_USED = new Stat[32000]; + public static final Stat[] ITEMS_BROKEN = new Stat[32000]; + + // For some reason vanilla stat IDs start at these numbers + private static final Integer MINE_BLOCK_PREFIX = 16777216; + private static final Integer USE_ITEM_PREFIX = 16908288; + private static final Integer BREAK_ITEM_PREFIX = 16973824; + private static final Integer CRAFT_ITEM_PREFIX = 16842752; + + public static void init() { + initBlocksMinedStats(); + initItemsUsedStats(); + initItemsBrokenStats(); + initItemsCraftedStats(); + } + + private static void initItemsCraftedStats() { + HashSet items = new HashSet<>(); + + for (var item : CraftingManager.getInstance().getRecipes()) { + var recipe = (CraftingRecipe) (Object) item; + + if (recipe.getResult() != null) { + items.add(recipe.getResult().getItem()); + } + } + + for (var itemStack : SmeltingManager.getInstance().getRecipes().values()) { + items.add(((ItemStack) (Object) itemStack).getItem()); + } + + for (Item item : items) { + if (item != null) { + int id = item.id; + ITEMS_CRAFTED[id] = new Stat("stat.craftItem." + id, CRAFT_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_CRAFTED); + } + + private static void initBlocksMinedStats() { + for (Block block : Block.BY_ID) { + if (block == null) { + continue; + } + + int id = block.id; + + if (id != 0 && block.hasStats()) { + BLOCKS_MINED[id] = new Stat("stat.mineBlock." + id, MINE_BLOCK_PREFIX + id).register(); + MINED.add(BLOCKS_MINED[id]); + } + } + + mergeBlockStats(BLOCKS_MINED); + } + + private static void initItemsUsedStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + ITEMS_USED[id] = new Stat("stat.useItem." + id, USE_ITEM_PREFIX + id).register(); + if (!(item instanceof BlockItem)) { + USED.add(ITEMS_USED[id]); + } + + } + + mergeBlockStats(ITEMS_USED); + } + + private static void initItemsBrokenStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + if (item.isDamageable()) { + ITEMS_BROKEN[id] = new Stat("stat.breakItem." + id, BREAK_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_BROKEN); + } + + private static void mergeBlockStats(Stat[] stats) { + mergeBlockStats(stats, Block.WATER, Block.FLOWING_WATER); + mergeBlockStats(stats, Block.LAVA, Block.FLOWING_LAVA); + mergeBlockStats(stats, Block.LIT_PUMPKIN, Block.PUMPKIN); + mergeBlockStats(stats, Block.LIT_FURNACE, Block.FURNACE); + mergeBlockStats(stats, Block.LIT_REDSTONE_ORE, Block.REDSTONE_ORE); + mergeBlockStats(stats, Block.POWERED_REPEATER, Block.REPEATER); + // mergeBlockStats(stats, Block.POWERED_COMPARATOR, Block.COMPARATOR); + mergeBlockStats(stats, Block.REDSTONE_TORCH, Block.UNLIT_REDSTONE_TORCH); + // mergeBlockStats(stats, Block.LIT_REDSTONE_LAMP, Block.REDSTONE_LAMP); + mergeBlockStats(stats, Block.RED_MUSHROOM, Block.BROWN_MUSHROOM); + mergeBlockStats(stats, Block.DOUBLE_STONE_SLAB, Block.STONE_SLAB); + // mergeBlockStats(stats, Block.DOUBLE_WOODEN_SLAB, Block.WOODEN_SLAB); + mergeBlockStats(stats, Block.GRASS, Block.DIRT); + mergeBlockStats(stats, Block.FARMLAND, Block.DIRT); + } + + private static void mergeBlockStats(Stat[] stats, Block block1, Block block2) { + int id1 = block1.id; + int id2 = block2.id; + if (stats[id1] != null && stats[id2] == null) { + stats[id2] = stats[id1]; + } else { + ALL.remove(stats[id1]); + MINED.remove(stats[id1]); + GENERAL.remove(stats[id1]); + stats[id1] = stats[id2]; + } + } + + public static Stat createEntityKillStat(String key) { + return new Stat("stat.killEntity." + key, null).register(); + } + + public static Stat createKilledByEntityStat(String key) { + return new Stat("stat.entityKilledBy." + key, null).register(); + } + + public static Stat byKey(String key) { + return BY_KEY.get(key); + } + + public static Stat byVanillaId(Integer id) { + return BY_VANILLA_ID.get(id); + } + + public static Stat getEntityKillStat(Entity entity) { + return byKey("stat.killEntity." + Entities.getKey(entity)); + } + + public static Stat getKilledByEntityStat(Entity entity) { + return byKey("stat.entityKilledBy." + Entities.getKey(entity)); + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java new file mode 100644 index 0000000..283a2e6 --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java @@ -0,0 +1,20 @@ +package net.lostluma.server_stats.types; + +import org.jetbrains.annotations.Nullable; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; + +public interface StatsPlayer { + public default @Nullable ServerPlayerStats server_stats$getStats() { + throw new RuntimeException("No implementation for server_stats$getStats found."); + } + + public default void server_stats$incrementStat(Stat stat, int amount) { + throw new RuntimeException("No implementation for server_stats$incrementStat found."); + } + + public default void server_stats$saveStats() { + throw new RuntimeException("No implementation for server_stats$saveStats found."); + } +} diff --git a/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java new file mode 100644 index 0000000..d55c0ab --- /dev/null +++ b/versions/1.0.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java @@ -0,0 +1,24 @@ +package net.lostluma.server_stats.utils; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.lostluma.server_stats.Constants; +import org.quiltmc.loader.api.QuiltLoader; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +public class Serialization { + private static final String BASE_PATH = "assets/" + Constants.MOD_ID + "/data"; + + public static Map getFromAssets(String name) throws IOException { + var container = QuiltLoader.getModContainer(Constants.MOD_ID).orElseThrow(); + var path = container.getPath(BASE_PATH + "/" + name + ".json"); + + Type type = new TypeToken>(){}.getType(); + return new Gson().fromJson(Files.readString(path, StandardCharsets.UTF_8), type); + } +} \ No newline at end of file diff --git a/versions/1.0.0-client/src/main/resources/assets/server_stats_mixins/icon.png b/versions/1.0.0-client/src/main/resources/assets/server_stats_mixins/icon.png new file mode 100644 index 0000000..8714235 Binary files /dev/null and b/versions/1.0.0-client/src/main/resources/assets/server_stats_mixins/icon.png differ diff --git a/versions/1.0.0-client/src/main/resources/quilt.mod.json b/versions/1.0.0-client/src/main/resources/quilt.mod.json new file mode 100644 index 0000000..8aac67b --- /dev/null +++ b/versions/1.0.0-client/src/main/resources/quilt.mod.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "quilt_loader": { + "group": "net.lostluma", + "id": "server_stats_mixins", + "version": "${version}", + "metadata": { + "name": "Server Stats Mixins", + "description": "Implementation of server-side statistics for Minecraft 1.0.0 on the client.", + "contributors": { + "LostLuma": "Owner" + }, + "contact": { + "homepage": "https://go.lostluma.net/server-stats", + "issues": "https://go.lostluma.net/server-stats-issues", + "sources": "https://go.lostluma.net/server-stats-source" + }, + "license": { + "name": "LGPL License", + "id": "LGPL-3.0-or-later", + "url": "https://go.lostluma.net/server-stats-license" + }, + "icon": "assets/server_stats_mixins/icon.png" + }, + "intermediate_mappings": "net.fabricmc:intermediary", + "depends": [ + { + "id": "minecraft", + "versions": "=1.0.0" + } + ] + }, + "minecraft": { + "environment": "client" + }, + "mixin": "server_stats.mixins.json", + "access_widener" : "server_stats.accesswidener", + "quilt_loom": { + "injected_interfaces": { + "net/minecraft/unmapped/C_9590849": [ + "net/lostluma/server_stats/types/StatsPlayer" + ] + } + }, + "modmenu": { + "parent": "server_stats" + } +} diff --git a/versions/1.0.0-client/src/main/resources/server_stats.accesswidener b/versions/1.0.0-client/src/main/resources/server_stats.accesswidener new file mode 100644 index 0000000..894292a --- /dev/null +++ b/versions/1.0.0-client/src/main/resources/server_stats.accesswidener @@ -0,0 +1,3 @@ +accessWidener v2 named + +accessible field net/minecraft/client/Minecraft INSTANCE Lnet/minecraft/client/Minecraft; diff --git a/versions/1.0.0-client/src/main/resources/server_stats.mixins.json b/versions/1.0.0-client/src/main/resources/server_stats.mixins.json new file mode 100644 index 0000000..b04f1f5 --- /dev/null +++ b/versions/1.0.0-client/src/main/resources/server_stats.mixins.json @@ -0,0 +1,19 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "net.lostluma.server_stats.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [ + "common.EntitiesMixin", + "common.PlayerEntityMixin", + "common.WorldMixin" + ], + "client": [ + "client.LocalPlayerEntityMixin", + "client.MinecraftMixin" + ], + "server": [], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/versions/1.1.0-client/build.gradle b/versions/1.1.0-client/build.gradle new file mode 100644 index 0000000..163228f --- /dev/null +++ b/versions/1.1.0-client/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'maven-publish' + alias libs.plugins.shadow + alias libs.plugins.quilt.loom + alias libs.plugins.ploceus +} + +apply from: "${rootProject.projectDir}/gradle/common.gradle" + +group = project.maven_group +version = generateVersionWithMetadata() + +base { + archivesName = project.archives_base_name +} + +loom { + clientOnlyMinecraftJar() + + accessWidenerPath = file("src/main/resources/server_stats.accesswidener") +} + +ploceus { + clientOnlyMappings() +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + + mappings loom.layered { + mappings "net.ornithemc:feather:${project.feather_version}:v2" + addLayer ploceus.nestedMappings() // Required for nests + } + + nests "net.ornithemc:nests:${project.nests_version}" + modImplementation libs.quilt.loader + + implementation libs.gson +} + +shadowJar { + configurations = [project.configurations.shadow] + relocate "com.google", "net.lostluma.server_stats.external" +} + +remapJar { + inputFile.set shadowJar.archiveFile + dependsOn shadowJar +} diff --git a/versions/1.1.0-client/gradle.properties b/versions/1.1.0-client/gradle.properties new file mode 100644 index 0000000..841bdca --- /dev/null +++ b/versions/1.1.0-client/gradle.properties @@ -0,0 +1,8 @@ +# Mod Properties +archives_base_name = server-stats-mixins-client + +# Minecraft Properties +minecraft_version = 1.1 + +nests_version = 1.1-client+build.1 +feather_version = 1.1-client+build.11 diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/Constants.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/Constants.java new file mode 100644 index 0000000..4e642c7 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/Constants.java @@ -0,0 +1,6 @@ +package net.lostluma.server_stats; + +public class Constants { + public static final String MOD_ID = "server_stats"; + public static final String STATS_PACKET_CHANNEL = MOD_ID + "|s"; +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java new file mode 100644 index 0000000..ee62753 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java @@ -0,0 +1,30 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.entity.living.player.InputPlayerEntity; +import net.minecraft.client.entity.living.player.LocalPlayerEntity; +import net.minecraft.entity.living.player.PlayerEntity; +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({ InputPlayerEntity.class, LocalPlayerEntity.class }) +public class LocalPlayerEntityMixin { + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Inject(method = "incrementStat(Lnet/minecraft/stat/Stat;I)V", at = @At("HEAD")) + private void incrementStat(net.minecraft.stat.Stat vanillaStat, int amount, CallbackInfo callbackInfo) { + if (vanillaStat == null) { + return; + } + + var stat = Stats.byVanillaId(vanillaStat.id); + + if (stat != null) { + this.getPlayer().server_stats$incrementStat(stat, amount); + } + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java new file mode 100644 index 0000000..9d9fad4 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java @@ -0,0 +1,33 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.living.player.InputPlayerEntity; +import net.minecraft.world.WorldSettings; +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.CallbackInfo; + +@Mixin(Minecraft.class) +public class MinecraftMixin { + @Shadow + public InputPlayerEntity player; + + @Inject(method = "", at = @At("TAIL")) + private void onInit(CallbackInfo callbackInfo) { + Stats.init(); + } + + @Inject(method = "startGame", at = @At("HEAD")) + private void startGame(String worldDir, String worldName, WorldSettings worldSettings, CallbackInfo callbackInfo) { + ServerPlayerStats.setWorldDirectory(String.format("saves/%s", worldDir)); + } + + @Inject(method = "m_4977780", at = @At("TAIL")) + private void changeDimension(int dimension, CallbackInfo callbackInfo) { + this.player.server_stats$saveStats(); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java new file mode 100644 index 0000000..6cee26a --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java @@ -0,0 +1,36 @@ +package net.lostluma.server_stats.mixin.client; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.lostluma.server_stats.Constants; +import net.minecraft.client.Minecraft; +import net.minecraft.client.network.handler.ClientNetworkHandler; +import net.minecraft.network.PacketHandler; +import net.minecraft.network.packet.CustomPayloadPacket; +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.CallbackInfo; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Mixin(PacketHandler.class) +public class PacketHandlerMixin { + @Inject(method = "handleCustomPayload", at = @At("HEAD"), cancellable = true) + private void handleCustomPayload(CustomPayloadPacket packet, CallbackInfo callbackInfo) { + if (!packet.channel.equals(Constants.STATS_PACKET_CHANNEL)) { + return; + } + + var data = new String(packet.data, StandardCharsets.UTF_8); + + Type type = new TypeToken>(){}.getType(); + Map result = new Gson().fromJson(data, type); + + Minecraft.INSTANCE.statHandler.player_stats$override(result); + callbackInfo.cancel(); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java new file mode 100644 index 0000000..fb3e8db --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java @@ -0,0 +1,72 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.types.OverridableStats; +import net.lostluma.server_stats.utils.Serialization; +import net.minecraft.stat.PlayerStats; +import net.minecraft.stat.Stat; +import net.minecraft.stat.Stats; +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.CallbackInfo; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.regex.Pattern; + +@Mixin(PlayerStats.class) +public class PlayerStatsMixin implements OverridableStats { + @Shadow + + private Map stats; + + private static final Pattern STAT_PATTERN = Pattern.compile("^(?stat.(?:breakItem|craftItem|mineBlock|useItem).)(?\\d+)$"); + + @Inject(method = "", at = @At("TAIL")) + private void onInit(CallbackInfo callbackInfo) { + this.stats = Collections.synchronizedMap(this.stats); + } + + @Override + public void player_stats$override(Map override) { + try { + this.player_stats$override0(override); + } catch (IOException e) { + System.out.println("Failed to override local stats " + e.getMessage()); + } + } + + private void player_stats$override0(Map override) throws IOException { + // Reset all stats for the case where the world joined has some stats not set + this.stats.keySet().removeAll( + this.stats.keySet().stream().filter(integer -> !integer.isAchievement()).toList() + ); + + var ids = Serialization.getFromAssets("stat_ids"); + var prefixes = Serialization.getFromAssets("stat_id_prefixes"); + + override.forEach((key, value) -> { + Stat stat = null; + var match = STAT_PATTERN.matcher(key); + + if (ids.containsKey(key)) { + stat = Stats.byKey(ids.get(key)); + } else if (match.matches()) { + var id = match.group("id"); + var type = match.group("type"); + + if (prefixes.containsKey(type)) { + stat = Stats.byKey(Integer.parseInt(id) + prefixes.get(type)); + } + } + + if (stat != null) { + this.stats.put(stat, value); + } else { + System.out.println("No client-side stat found for key " + key); + } + }); + } +} \ No newline at end of file diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java new file mode 100644 index 0000000..49cff01 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java @@ -0,0 +1,18 @@ +package net.lostluma.server_stats.mixin.common; + +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; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.Entities; + +@Mixin(Entities.class) +public class EntitiesMixin { + @Inject(method = "register", at = @At("TAIL")) + private static void registerWithSpawnEgg(Class type, String key, int id, CallbackInfo callbackInfo) { + Stats.createEntityKillStat(key); + Stats.createKilledByEntityStat(key); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java new file mode 100644 index 0000000..5181565 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java @@ -0,0 +1,75 @@ +package net.lostluma.server_stats.mixin.common; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.living.LivingEntity; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; +import net.lostluma.server_stats.types.StatsPlayer; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.living.player.PlayerEntity; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public class PlayerEntityMixin implements StatsPlayer { + @Unique + private ServerPlayerStats server_stats$serverPlayerStats = null; + + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Override + public void server_stats$incrementStat(Stat stat, int amount) { + var stats = this.server_stats$getStats(); + var player = (PlayerEntity)(Object)this; + + if (stats != null) { + stats.increment(player, stat, amount); + } + } + + @Override + public void server_stats$saveStats() { + var stats = this.server_stats$getStats(); + + if (stats != null) { + stats.save(); + } + } + + @Override + public @Nullable ServerPlayerStats server_stats$getStats() { + var player = (PlayerEntity)(Object)this; + + // Unmapped method returns true when the server is multiplayer + if (Minecraft.INSTANCE.m_2812472()) { + return null; + } + + if (this.server_stats$serverPlayerStats == null) { + this.server_stats$serverPlayerStats = new ServerPlayerStats(player); + } + + return this.server_stats$serverPlayerStats; + } + + @Inject(method = "onKilled", at = @At("HEAD")) + private void onKilled(DamageSource source, CallbackInfo callbackInfo) { + if (source.getAttacker() != null) { + var attacker = source.getAttacker(); + this.getPlayer().server_stats$incrementStat(Stats.getKilledByEntityStat(attacker), 1); + } + } + + @Inject(method = "onKill", at = @At("HEAD")) + private void onKill(LivingEntity entity, CallbackInfo callbackInfo) { + this.getPlayer().server_stats$incrementStat(Stats.getEntityKillStat(entity), 1); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java new file mode 100644 index 0000000..f2ab2b9 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java @@ -0,0 +1,25 @@ +package net.lostluma.server_stats.mixin.common; + +import java.util.List; + +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.CallbackInfo; + +import net.minecraft.entity.living.player.PlayerEntity; +import net.minecraft.world.World; + +@Mixin(World.class) +public class WorldMixin { + @Shadow + public List players; + + @Inject(method = "saveData", at = @At("TAIL")) + public void onSave(CallbackInfo callbackInfo) { + for (PlayerEntity player : this.players) { + player.server_stats$saveStats(); + } + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java new file mode 100644 index 0000000..fed236a --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java @@ -0,0 +1,16 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +public class GeneralStat extends Stat { + public GeneralStat(String key, @Nullable Integer vanillaId) { + super(key, vanillaId); + } + + @Override + public Stat register() { + super.register(); + Stats.GENERAL.add(this); + return this; + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java new file mode 100644 index 0000000..9f92288 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java @@ -0,0 +1,143 @@ +package net.lostluma.server_stats.stats; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import net.minecraft.entity.living.player.PlayerEntity; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("squid:S2629") // Unconditional method call in log call +public class ServerPlayerStats { + private final String name; + protected final Map counters; + + private static @Nullable Path STATS = null; + + // Use Minecraft logger as it is already properly set up + private static final Logger LOGGER = Logger.getLogger("Minecraft"); + + private static final FileAttribute[] DEFAULT_ATTRIBUTES = getDefaultFileAttributes(); + + public ServerPlayerStats(PlayerEntity player) { + this.name = player.name; + this.counters = new ConcurrentHashMap<>(); + + this.load(); + } + + public static void setWorldDirectory(String worldDirName) { + STATS = Path.of(worldDirName).resolve("stats"); + } + + public void increment(PlayerEntity player, Stat stat, int amount) { + if (stat != null) { + this.set(player, stat, this.get(stat) + amount); + } + } + + public void set(PlayerEntity player, Stat stat, int value) { + this.counters.put(stat, value); + } + + public int get(Stat stat) { + return this.counters.getOrDefault(stat, 0); + } + + public void load() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + if (Files.exists(path)) { + try { + this.deserialize(); + } catch (IOException e) { + LOGGER.severe("Couldn't read statistics file " + path); + } catch (JsonParseException e) { + LOGGER.severe("Couldn't parse statistics file " + path); + } + } + } + + public void save() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + try { + Files.createDirectories(STATS); + + Path temp = Files.createTempFile(STATS, this.name, ".json", DEFAULT_ATTRIBUTES); + Files.writeString(temp, this.serialize(), StandardCharsets.UTF_8); + + Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE); // Prevent issues on server crash + } catch (IOException e) { + LOGGER.warning(String.format("Failed to write stats to %s! %s", path, e.toString())); + } + } + + public void deserialize() throws IOException { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + JsonElement root = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); + + if (!root.isJsonObject()) { + return; + } + + JsonObject data = root.getAsJsonObject(); + + for (Entry entry : data.entrySet()) { + JsonElement value = entry.getValue(); + Stat stat = Stats.byKey(entry.getKey()); + + if (stat != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + this.counters.put(stat, value.getAsInt()); + } else { + LOGGER.warning(String.format("Failed to read stat %s in %s! It's either unknown or has invalid data.", + entry.getKey(), path)); + } + } + } + + public String serialize() { + JsonObject result = new JsonObject(); + + for (Entry counter : this.counters.entrySet()) { + result.addProperty(counter.getKey().key, counter.getValue()); + } + + return result.toString(); + } + + private static FileAttribute[] getDefaultFileAttributes() { + if (!System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("linux")) { + return new FileAttribute[0]; + } + + return new FileAttribute[]{PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-r--r--"))}; + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java new file mode 100644 index 0000000..aa387a4 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stat.java @@ -0,0 +1,47 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +/* + * A very cut-down version of the vanilla 1.7.10 Stat class. + */ +public class Stat { + public final String key; + + public final @Nullable Integer vanillaId; + + public Stat(String key, @Nullable Integer vanillaId) { + this.key = key; + this.vanillaId = vanillaId; + } + + public Stat register() { + if (Stats.BY_KEY.containsKey(this.key)) { + throw new RuntimeException("Duplicate stat id: \"" + ((Stat)Stats.BY_KEY.get(this.key)).key + "\" and \"" + this.key + "."); + } else { + Stats.ALL.add(this); + Stats.BY_KEY.put(this.key, this); + + if (this.vanillaId != null) { + Stats.BY_VANILLA_ID.put(this.vanillaId, this); + } + + return this; + } + } + + public boolean equals(Object object) { + if (this == object) { + return true; + } else if (object != null && this.getClass() == object.getClass()) { + Stat var2 = (Stat)object; + return this.key.equals(var2.key); + } else { + return false; + } + } + + public int hashCode() { + return this.key.hashCode(); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java new file mode 100644 index 0000000..f52eaa5 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/stats/Stats.java @@ -0,0 +1,197 @@ +package net.lostluma.server_stats.stats; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import net.minecraft.block.Block; +import net.minecraft.crafting.CraftingManager; +import net.minecraft.crafting.recipe.CraftingRecipe; +import net.minecraft.entity.Entities; +import net.minecraft.entity.Entity; +import net.minecraft.item.BlockItem; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.smelting.SmeltingManager; + +/* + * A cut-down and simplified version of the vanilla 1.7.10 Stats class. + */ +@SuppressWarnings({ "squid:S3008", "squid:S2386" }) +public class Stats { + protected static Map BY_KEY = new HashMap<>(); + protected static Map BY_VANILLA_ID = new HashMap<>(); + + public static List ALL = new ArrayList<>(); + public static List GENERAL = new ArrayList<>(); + public static List USED = new ArrayList<>(); + public static List MINED = new ArrayList<>(); + + public static Stat GAMES_LEFT = new GeneralStat("stat.leaveGame", 1004).register(); + public static Stat MINUTES_PLAYED = new GeneralStat("stat.playOneMinute", 1100).register(); + public static Stat CM_WALKED = new GeneralStat("stat.walkOneCm", 2000).register(); + public static Stat CM_SWUM = new GeneralStat("stat.swimOneCm", 2001).register(); + public static Stat CM_FALLEN = new GeneralStat("stat.fallOneCm", 2002).register(); + public static Stat CM_CLIMB = new GeneralStat("stat.climbOneCm", 2003).register(); + public static Stat CM_FLOWN = new GeneralStat("stat.flyOneCm", 2004).register(); + public static Stat CM_DIVEN = new GeneralStat("stat.diveOneCm", 2005).register(); + public static Stat CM_MINECART = new GeneralStat("stat.minecartOneCm", 2006).register(); + public static Stat CM_SAILED = new GeneralStat("stat.boatOneCm", 2007).register(); + public static Stat CM_PIG = new GeneralStat("stat.pigOneCm", 2008).register(); + public static Stat JUMPS = new GeneralStat("stat.jump", 2010).register(); + public static Stat DROPS = new GeneralStat("stat.drop", 2011).register(); + public static Stat DAMAGE_DEALT = new GeneralStat("stat.damageDealt", 2020).register(); + public static Stat DAMAGE_TAKEN = new GeneralStat("stat.damageTaken", 2021).register(); + public static Stat DEATHS = new GeneralStat("stat.deaths", 2022).register(); + public static Stat MOBS_KILLED = new GeneralStat("stat.mobKills", 2023).register(); + public static Stat PLAYERS_KILLED = new GeneralStat("stat.playerKills", 2024).register(); + public static Stat FISH_CAUGHT = new GeneralStat("stat.fishCaught", 2025).register(); + + public static final Stat[] BLOCKS_MINED = new Stat[4096]; + public static final Stat[] ITEMS_CRAFTED = new Stat[32000]; + public static final Stat[] ITEMS_USED = new Stat[32000]; + public static final Stat[] ITEMS_BROKEN = new Stat[32000]; + + // For some reason vanilla stat IDs start at these numbers + private static final Integer MINE_BLOCK_PREFIX = 16777216; + private static final Integer USE_ITEM_PREFIX = 16908288; + private static final Integer BREAK_ITEM_PREFIX = 16973824; + private static final Integer CRAFT_ITEM_PREFIX = 16842752; + + public static void init() { + initBlocksMinedStats(); + initItemsUsedStats(); + initItemsBrokenStats(); + initItemsCraftedStats(); + } + + private static void initItemsCraftedStats() { + HashSet items = new HashSet<>(); + + for (var item : CraftingManager.getInstance().getRecipes()) { + var recipe = (CraftingRecipe) (Object) item; + + if (recipe.getResult() != null) { + items.add(recipe.getResult().getItem()); + } + } + + for (var itemStack : SmeltingManager.getInstance().getRecipes().values()) { + items.add(((ItemStack) (Object) itemStack).getItem()); + } + + for (Item item : items) { + if (item != null) { + int id = item.id; + ITEMS_CRAFTED[id] = new Stat("stat.craftItem." + id, CRAFT_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_CRAFTED); + } + + private static void initBlocksMinedStats() { + for (Block block : Block.BY_ID) { + if (block == null) { + continue; + } + + int id = block.id; + + if (id != 0 && block.hasStats()) { + BLOCKS_MINED[id] = new Stat("stat.mineBlock." + id, MINE_BLOCK_PREFIX + id).register(); + MINED.add(BLOCKS_MINED[id]); + } + } + + mergeBlockStats(BLOCKS_MINED); + } + + private static void initItemsUsedStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + ITEMS_USED[id] = new Stat("stat.useItem." + id, USE_ITEM_PREFIX + id).register(); + if (!(item instanceof BlockItem)) { + USED.add(ITEMS_USED[id]); + } + + } + + mergeBlockStats(ITEMS_USED); + } + + private static void initItemsBrokenStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + if (item.isDamageable()) { + ITEMS_BROKEN[id] = new Stat("stat.breakItem." + id, BREAK_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_BROKEN); + } + + private static void mergeBlockStats(Stat[] stats) { + mergeBlockStats(stats, Block.WATER, Block.FLOWING_WATER); + mergeBlockStats(stats, Block.LAVA, Block.FLOWING_LAVA); + mergeBlockStats(stats, Block.LIT_PUMPKIN, Block.PUMPKIN); + mergeBlockStats(stats, Block.LIT_FURNACE, Block.FURNACE); + mergeBlockStats(stats, Block.LIT_REDSTONE_ORE, Block.REDSTONE_ORE); + mergeBlockStats(stats, Block.POWERED_REPEATER, Block.REPEATER); + // mergeBlockStats(stats, Block.POWERED_COMPARATOR, Block.COMPARATOR); + mergeBlockStats(stats, Block.REDSTONE_TORCH, Block.UNLIT_REDSTONE_TORCH); + // mergeBlockStats(stats, Block.LIT_REDSTONE_LAMP, Block.REDSTONE_LAMP); + mergeBlockStats(stats, Block.RED_MUSHROOM, Block.BROWN_MUSHROOM); + mergeBlockStats(stats, Block.DOUBLE_STONE_SLAB, Block.STONE_SLAB); + // mergeBlockStats(stats, Block.DOUBLE_WOODEN_SLAB, Block.WOODEN_SLAB); + mergeBlockStats(stats, Block.GRASS, Block.DIRT); + mergeBlockStats(stats, Block.FARMLAND, Block.DIRT); + } + + private static void mergeBlockStats(Stat[] stats, Block block1, Block block2) { + int id1 = block1.id; + int id2 = block2.id; + if (stats[id1] != null && stats[id2] == null) { + stats[id2] = stats[id1]; + } else { + ALL.remove(stats[id1]); + MINED.remove(stats[id1]); + GENERAL.remove(stats[id1]); + stats[id1] = stats[id2]; + } + } + + public static Stat createEntityKillStat(String key) { + return new Stat("stat.killEntity." + key, null).register(); + } + + public static Stat createKilledByEntityStat(String key) { + return new Stat("stat.entityKilledBy." + key, null).register(); + } + + public static Stat byKey(String key) { + return BY_KEY.get(key); + } + + public static Stat byVanillaId(Integer id) { + return BY_VANILLA_ID.get(id); + } + + public static Stat getEntityKillStat(Entity entity) { + return byKey("stat.killEntity." + Entities.getKey(entity)); + } + + public static Stat getKilledByEntityStat(Entity entity) { + return byKey("stat.entityKilledBy." + Entities.getKey(entity)); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java new file mode 100644 index 0000000..894364b --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java @@ -0,0 +1,9 @@ +package net.lostluma.server_stats.types; + +import java.util.Map; + +public interface OverridableStats { + public default void player_stats$override(Map override) { + throw new RuntimeException("No implementation for player_stats$override found."); + } +} \ No newline at end of file diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java new file mode 100644 index 0000000..283a2e6 --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java @@ -0,0 +1,20 @@ +package net.lostluma.server_stats.types; + +import org.jetbrains.annotations.Nullable; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; + +public interface StatsPlayer { + public default @Nullable ServerPlayerStats server_stats$getStats() { + throw new RuntimeException("No implementation for server_stats$getStats found."); + } + + public default void server_stats$incrementStat(Stat stat, int amount) { + throw new RuntimeException("No implementation for server_stats$incrementStat found."); + } + + public default void server_stats$saveStats() { + throw new RuntimeException("No implementation for server_stats$saveStats found."); + } +} diff --git a/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java new file mode 100644 index 0000000..d55c0ab --- /dev/null +++ b/versions/1.1.0-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java @@ -0,0 +1,24 @@ +package net.lostluma.server_stats.utils; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.lostluma.server_stats.Constants; +import org.quiltmc.loader.api.QuiltLoader; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +public class Serialization { + private static final String BASE_PATH = "assets/" + Constants.MOD_ID + "/data"; + + public static Map getFromAssets(String name) throws IOException { + var container = QuiltLoader.getModContainer(Constants.MOD_ID).orElseThrow(); + var path = container.getPath(BASE_PATH + "/" + name + ".json"); + + Type type = new TypeToken>(){}.getType(); + return new Gson().fromJson(Files.readString(path, StandardCharsets.UTF_8), type); + } +} \ No newline at end of file diff --git a/versions/1.1.0-client/src/main/resources/assets/server_stats_mixins/icon.png b/versions/1.1.0-client/src/main/resources/assets/server_stats_mixins/icon.png new file mode 100644 index 0000000..8714235 Binary files /dev/null and b/versions/1.1.0-client/src/main/resources/assets/server_stats_mixins/icon.png differ diff --git a/versions/1.1.0-client/src/main/resources/quilt.mod.json b/versions/1.1.0-client/src/main/resources/quilt.mod.json new file mode 100644 index 0000000..8a76330 --- /dev/null +++ b/versions/1.1.0-client/src/main/resources/quilt.mod.json @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "quilt_loader": { + "group": "net.lostluma", + "id": "server_stats_mixins", + "version": "${version}", + "metadata": { + "name": "Server Stats Mixins", + "description": "Implementation of server-side statistics for Minecraft 1.1.0 on the client.", + "contributors": { + "LostLuma": "Owner" + }, + "contact": { + "homepage": "https://go.lostluma.net/server-stats", + "issues": "https://go.lostluma.net/server-stats-issues", + "sources": "https://go.lostluma.net/server-stats-source" + }, + "license": { + "name": "LGPL License", + "id": "LGPL-3.0-or-later", + "url": "https://go.lostluma.net/server-stats-license" + }, + "icon": "assets/server_stats_mixins/icon.png" + }, + "intermediate_mappings": "net.fabricmc:intermediary", + "depends": [ + { + "id": "minecraft", + "versions": "=1.1.0" + } + ] + }, + "minecraft": { + "environment": "client" + }, + "mixin": "server_stats.mixins.json", + "access_widener" : "server_stats.accesswidener", + "quilt_loom": { + "injected_interfaces": { + "net/minecraft/unmapped/C_9590849": [ + "net/lostluma/server_stats/types/StatsPlayer" + ], + "net/minecraft/unmapped/C_2854223": [ + "net/lostluma/server_stats/types/OverridableStats" + ] + } + }, + "modmenu": { + "parent": "server_stats" + } +} diff --git a/versions/1.1.0-client/src/main/resources/server_stats.accesswidener b/versions/1.1.0-client/src/main/resources/server_stats.accesswidener new file mode 100644 index 0000000..894292a --- /dev/null +++ b/versions/1.1.0-client/src/main/resources/server_stats.accesswidener @@ -0,0 +1,3 @@ +accessWidener v2 named + +accessible field net/minecraft/client/Minecraft INSTANCE Lnet/minecraft/client/Minecraft; diff --git a/versions/1.1.0-client/src/main/resources/server_stats.mixins.json b/versions/1.1.0-client/src/main/resources/server_stats.mixins.json new file mode 100644 index 0000000..f62644a --- /dev/null +++ b/versions/1.1.0-client/src/main/resources/server_stats.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "net.lostluma.server_stats.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [ + "common.EntitiesMixin", + "common.PlayerEntityMixin", + "common.WorldMixin" + ], + "client": [ + "client.LocalPlayerEntityMixin", + "client.MinecraftMixin", + "client.PacketHandlerMixin", + "client.PlayerStatsMixin" + ], + "server": [], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/versions/1.2.5-client/build.gradle b/versions/1.2.5-client/build.gradle new file mode 100644 index 0000000..163228f --- /dev/null +++ b/versions/1.2.5-client/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'maven-publish' + alias libs.plugins.shadow + alias libs.plugins.quilt.loom + alias libs.plugins.ploceus +} + +apply from: "${rootProject.projectDir}/gradle/common.gradle" + +group = project.maven_group +version = generateVersionWithMetadata() + +base { + archivesName = project.archives_base_name +} + +loom { + clientOnlyMinecraftJar() + + accessWidenerPath = file("src/main/resources/server_stats.accesswidener") +} + +ploceus { + clientOnlyMappings() +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + + mappings loom.layered { + mappings "net.ornithemc:feather:${project.feather_version}:v2" + addLayer ploceus.nestedMappings() // Required for nests + } + + nests "net.ornithemc:nests:${project.nests_version}" + modImplementation libs.quilt.loader + + implementation libs.gson +} + +shadowJar { + configurations = [project.configurations.shadow] + relocate "com.google", "net.lostluma.server_stats.external" +} + +remapJar { + inputFile.set shadowJar.archiveFile + dependsOn shadowJar +} diff --git a/versions/1.2.5-client/gradle.properties b/versions/1.2.5-client/gradle.properties new file mode 100644 index 0000000..6b7eb47 --- /dev/null +++ b/versions/1.2.5-client/gradle.properties @@ -0,0 +1,8 @@ +# Mod Properties +archives_base_name = server-stats-mixins-client + +# Minecraft Properties +minecraft_version = 1.2.5 + +nests_version = 1.2.5-client+build.1 +feather_version = 1.2.5-client+build.11 diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/Constants.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/Constants.java new file mode 100644 index 0000000..4e642c7 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/Constants.java @@ -0,0 +1,6 @@ +package net.lostluma.server_stats; + +public class Constants { + public static final String MOD_ID = "server_stats"; + public static final String STATS_PACKET_CHANNEL = MOD_ID + "|s"; +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java new file mode 100644 index 0000000..ee62753 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/LocalPlayerEntityMixin.java @@ -0,0 +1,30 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.entity.living.player.InputPlayerEntity; +import net.minecraft.client.entity.living.player.LocalPlayerEntity; +import net.minecraft.entity.living.player.PlayerEntity; +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({ InputPlayerEntity.class, LocalPlayerEntity.class }) +public class LocalPlayerEntityMixin { + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Inject(method = "incrementStat(Lnet/minecraft/stat/Stat;I)V", at = @At("HEAD")) + private void incrementStat(net.minecraft.stat.Stat vanillaStat, int amount, CallbackInfo callbackInfo) { + if (vanillaStat == null) { + return; + } + + var stat = Stats.byVanillaId(vanillaStat.id); + + if (stat != null) { + this.getPlayer().server_stats$incrementStat(stat, amount); + } + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java new file mode 100644 index 0000000..9d9fad4 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/MinecraftMixin.java @@ -0,0 +1,33 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.living.player.InputPlayerEntity; +import net.minecraft.world.WorldSettings; +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.CallbackInfo; + +@Mixin(Minecraft.class) +public class MinecraftMixin { + @Shadow + public InputPlayerEntity player; + + @Inject(method = "", at = @At("TAIL")) + private void onInit(CallbackInfo callbackInfo) { + Stats.init(); + } + + @Inject(method = "startGame", at = @At("HEAD")) + private void startGame(String worldDir, String worldName, WorldSettings worldSettings, CallbackInfo callbackInfo) { + ServerPlayerStats.setWorldDirectory(String.format("saves/%s", worldDir)); + } + + @Inject(method = "m_4977780", at = @At("TAIL")) + private void changeDimension(int dimension, CallbackInfo callbackInfo) { + this.player.server_stats$saveStats(); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java new file mode 100644 index 0000000..6cee26a --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PacketHandlerMixin.java @@ -0,0 +1,36 @@ +package net.lostluma.server_stats.mixin.client; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.lostluma.server_stats.Constants; +import net.minecraft.client.Minecraft; +import net.minecraft.client.network.handler.ClientNetworkHandler; +import net.minecraft.network.PacketHandler; +import net.minecraft.network.packet.CustomPayloadPacket; +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.CallbackInfo; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Mixin(PacketHandler.class) +public class PacketHandlerMixin { + @Inject(method = "handleCustomPayload", at = @At("HEAD"), cancellable = true) + private void handleCustomPayload(CustomPayloadPacket packet, CallbackInfo callbackInfo) { + if (!packet.channel.equals(Constants.STATS_PACKET_CHANNEL)) { + return; + } + + var data = new String(packet.data, StandardCharsets.UTF_8); + + Type type = new TypeToken>(){}.getType(); + Map result = new Gson().fromJson(data, type); + + Minecraft.INSTANCE.statHandler.player_stats$override(result); + callbackInfo.cancel(); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java new file mode 100644 index 0000000..fb3e8db --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/client/PlayerStatsMixin.java @@ -0,0 +1,72 @@ +package net.lostluma.server_stats.mixin.client; + +import net.lostluma.server_stats.types.OverridableStats; +import net.lostluma.server_stats.utils.Serialization; +import net.minecraft.stat.PlayerStats; +import net.minecraft.stat.Stat; +import net.minecraft.stat.Stats; +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.CallbackInfo; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.regex.Pattern; + +@Mixin(PlayerStats.class) +public class PlayerStatsMixin implements OverridableStats { + @Shadow + + private Map stats; + + private static final Pattern STAT_PATTERN = Pattern.compile("^(?stat.(?:breakItem|craftItem|mineBlock|useItem).)(?\\d+)$"); + + @Inject(method = "", at = @At("TAIL")) + private void onInit(CallbackInfo callbackInfo) { + this.stats = Collections.synchronizedMap(this.stats); + } + + @Override + public void player_stats$override(Map override) { + try { + this.player_stats$override0(override); + } catch (IOException e) { + System.out.println("Failed to override local stats " + e.getMessage()); + } + } + + private void player_stats$override0(Map override) throws IOException { + // Reset all stats for the case where the world joined has some stats not set + this.stats.keySet().removeAll( + this.stats.keySet().stream().filter(integer -> !integer.isAchievement()).toList() + ); + + var ids = Serialization.getFromAssets("stat_ids"); + var prefixes = Serialization.getFromAssets("stat_id_prefixes"); + + override.forEach((key, value) -> { + Stat stat = null; + var match = STAT_PATTERN.matcher(key); + + if (ids.containsKey(key)) { + stat = Stats.byKey(ids.get(key)); + } else if (match.matches()) { + var id = match.group("id"); + var type = match.group("type"); + + if (prefixes.containsKey(type)) { + stat = Stats.byKey(Integer.parseInt(id) + prefixes.get(type)); + } + } + + if (stat != null) { + this.stats.put(stat, value); + } else { + System.out.println("No client-side stat found for key " + key); + } + }); + } +} \ No newline at end of file diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java new file mode 100644 index 0000000..49cff01 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/EntitiesMixin.java @@ -0,0 +1,18 @@ +package net.lostluma.server_stats.mixin.common; + +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; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.Entities; + +@Mixin(Entities.class) +public class EntitiesMixin { + @Inject(method = "register", at = @At("TAIL")) + private static void registerWithSpawnEgg(Class type, String key, int id, CallbackInfo callbackInfo) { + Stats.createEntityKillStat(key); + Stats.createKilledByEntityStat(key); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java new file mode 100644 index 0000000..5181565 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/PlayerEntityMixin.java @@ -0,0 +1,75 @@ +package net.lostluma.server_stats.mixin.common; + +import net.lostluma.server_stats.stats.Stats; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.living.LivingEntity; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; +import net.lostluma.server_stats.types.StatsPlayer; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.living.player.PlayerEntity; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public class PlayerEntityMixin implements StatsPlayer { + @Unique + private ServerPlayerStats server_stats$serverPlayerStats = null; + + private PlayerEntity getPlayer() { + return (PlayerEntity) (Object) this; + } + + @Override + public void server_stats$incrementStat(Stat stat, int amount) { + var stats = this.server_stats$getStats(); + var player = (PlayerEntity)(Object)this; + + if (stats != null) { + stats.increment(player, stat, amount); + } + } + + @Override + public void server_stats$saveStats() { + var stats = this.server_stats$getStats(); + + if (stats != null) { + stats.save(); + } + } + + @Override + public @Nullable ServerPlayerStats server_stats$getStats() { + var player = (PlayerEntity)(Object)this; + + // Unmapped method returns true when the server is multiplayer + if (Minecraft.INSTANCE.m_2812472()) { + return null; + } + + if (this.server_stats$serverPlayerStats == null) { + this.server_stats$serverPlayerStats = new ServerPlayerStats(player); + } + + return this.server_stats$serverPlayerStats; + } + + @Inject(method = "onKilled", at = @At("HEAD")) + private void onKilled(DamageSource source, CallbackInfo callbackInfo) { + if (source.getAttacker() != null) { + var attacker = source.getAttacker(); + this.getPlayer().server_stats$incrementStat(Stats.getKilledByEntityStat(attacker), 1); + } + } + + @Inject(method = "onKill", at = @At("HEAD")) + private void onKill(LivingEntity entity, CallbackInfo callbackInfo) { + this.getPlayer().server_stats$incrementStat(Stats.getEntityKillStat(entity), 1); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java new file mode 100644 index 0000000..f2ab2b9 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/mixin/common/WorldMixin.java @@ -0,0 +1,25 @@ +package net.lostluma.server_stats.mixin.common; + +import java.util.List; + +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.CallbackInfo; + +import net.minecraft.entity.living.player.PlayerEntity; +import net.minecraft.world.World; + +@Mixin(World.class) +public class WorldMixin { + @Shadow + public List players; + + @Inject(method = "saveData", at = @At("TAIL")) + public void onSave(CallbackInfo callbackInfo) { + for (PlayerEntity player : this.players) { + player.server_stats$saveStats(); + } + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java new file mode 100644 index 0000000..fed236a --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/GeneralStat.java @@ -0,0 +1,16 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +public class GeneralStat extends Stat { + public GeneralStat(String key, @Nullable Integer vanillaId) { + super(key, vanillaId); + } + + @Override + public Stat register() { + super.register(); + Stats.GENERAL.add(this); + return this; + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java new file mode 100644 index 0000000..9f92288 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/ServerPlayerStats.java @@ -0,0 +1,143 @@ +package net.lostluma.server_stats.stats; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import net.minecraft.entity.living.player.PlayerEntity; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("squid:S2629") // Unconditional method call in log call +public class ServerPlayerStats { + private final String name; + protected final Map counters; + + private static @Nullable Path STATS = null; + + // Use Minecraft logger as it is already properly set up + private static final Logger LOGGER = Logger.getLogger("Minecraft"); + + private static final FileAttribute[] DEFAULT_ATTRIBUTES = getDefaultFileAttributes(); + + public ServerPlayerStats(PlayerEntity player) { + this.name = player.name; + this.counters = new ConcurrentHashMap<>(); + + this.load(); + } + + public static void setWorldDirectory(String worldDirName) { + STATS = Path.of(worldDirName).resolve("stats"); + } + + public void increment(PlayerEntity player, Stat stat, int amount) { + if (stat != null) { + this.set(player, stat, this.get(stat) + amount); + } + } + + public void set(PlayerEntity player, Stat stat, int value) { + this.counters.put(stat, value); + } + + public int get(Stat stat) { + return this.counters.getOrDefault(stat, 0); + } + + public void load() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + if (Files.exists(path)) { + try { + this.deserialize(); + } catch (IOException e) { + LOGGER.severe("Couldn't read statistics file " + path); + } catch (JsonParseException e) { + LOGGER.severe("Couldn't parse statistics file " + path); + } + } + } + + public void save() { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + + try { + Files.createDirectories(STATS); + + Path temp = Files.createTempFile(STATS, this.name, ".json", DEFAULT_ATTRIBUTES); + Files.writeString(temp, this.serialize(), StandardCharsets.UTF_8); + + Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE); // Prevent issues on server crash + } catch (IOException e) { + LOGGER.warning(String.format("Failed to write stats to %s! %s", path, e.toString())); + } + } + + public void deserialize() throws IOException { + if (Objects.isNull(STATS)) { + throw new RuntimeException("Stats directory unset."); + } + + Path path = STATS.resolve(this.name + ".json"); + JsonElement root = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); + + if (!root.isJsonObject()) { + return; + } + + JsonObject data = root.getAsJsonObject(); + + for (Entry entry : data.entrySet()) { + JsonElement value = entry.getValue(); + Stat stat = Stats.byKey(entry.getKey()); + + if (stat != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + this.counters.put(stat, value.getAsInt()); + } else { + LOGGER.warning(String.format("Failed to read stat %s in %s! It's either unknown or has invalid data.", + entry.getKey(), path)); + } + } + } + + public String serialize() { + JsonObject result = new JsonObject(); + + for (Entry counter : this.counters.entrySet()) { + result.addProperty(counter.getKey().key, counter.getValue()); + } + + return result.toString(); + } + + private static FileAttribute[] getDefaultFileAttributes() { + if (!System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("linux")) { + return new FileAttribute[0]; + } + + return new FileAttribute[]{PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-r--r--"))}; + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stat.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stat.java new file mode 100644 index 0000000..aa387a4 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stat.java @@ -0,0 +1,47 @@ +package net.lostluma.server_stats.stats; + +import org.jetbrains.annotations.Nullable; + +/* + * A very cut-down version of the vanilla 1.7.10 Stat class. + */ +public class Stat { + public final String key; + + public final @Nullable Integer vanillaId; + + public Stat(String key, @Nullable Integer vanillaId) { + this.key = key; + this.vanillaId = vanillaId; + } + + public Stat register() { + if (Stats.BY_KEY.containsKey(this.key)) { + throw new RuntimeException("Duplicate stat id: \"" + ((Stat)Stats.BY_KEY.get(this.key)).key + "\" and \"" + this.key + "."); + } else { + Stats.ALL.add(this); + Stats.BY_KEY.put(this.key, this); + + if (this.vanillaId != null) { + Stats.BY_VANILLA_ID.put(this.vanillaId, this); + } + + return this; + } + } + + public boolean equals(Object object) { + if (this == object) { + return true; + } else if (object != null && this.getClass() == object.getClass()) { + Stat var2 = (Stat)object; + return this.key.equals(var2.key); + } else { + return false; + } + } + + public int hashCode() { + return this.key.hashCode(); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stats.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stats.java new file mode 100644 index 0000000..f52eaa5 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/stats/Stats.java @@ -0,0 +1,197 @@ +package net.lostluma.server_stats.stats; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import net.minecraft.block.Block; +import net.minecraft.crafting.CraftingManager; +import net.minecraft.crafting.recipe.CraftingRecipe; +import net.minecraft.entity.Entities; +import net.minecraft.entity.Entity; +import net.minecraft.item.BlockItem; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.smelting.SmeltingManager; + +/* + * A cut-down and simplified version of the vanilla 1.7.10 Stats class. + */ +@SuppressWarnings({ "squid:S3008", "squid:S2386" }) +public class Stats { + protected static Map BY_KEY = new HashMap<>(); + protected static Map BY_VANILLA_ID = new HashMap<>(); + + public static List ALL = new ArrayList<>(); + public static List GENERAL = new ArrayList<>(); + public static List USED = new ArrayList<>(); + public static List MINED = new ArrayList<>(); + + public static Stat GAMES_LEFT = new GeneralStat("stat.leaveGame", 1004).register(); + public static Stat MINUTES_PLAYED = new GeneralStat("stat.playOneMinute", 1100).register(); + public static Stat CM_WALKED = new GeneralStat("stat.walkOneCm", 2000).register(); + public static Stat CM_SWUM = new GeneralStat("stat.swimOneCm", 2001).register(); + public static Stat CM_FALLEN = new GeneralStat("stat.fallOneCm", 2002).register(); + public static Stat CM_CLIMB = new GeneralStat("stat.climbOneCm", 2003).register(); + public static Stat CM_FLOWN = new GeneralStat("stat.flyOneCm", 2004).register(); + public static Stat CM_DIVEN = new GeneralStat("stat.diveOneCm", 2005).register(); + public static Stat CM_MINECART = new GeneralStat("stat.minecartOneCm", 2006).register(); + public static Stat CM_SAILED = new GeneralStat("stat.boatOneCm", 2007).register(); + public static Stat CM_PIG = new GeneralStat("stat.pigOneCm", 2008).register(); + public static Stat JUMPS = new GeneralStat("stat.jump", 2010).register(); + public static Stat DROPS = new GeneralStat("stat.drop", 2011).register(); + public static Stat DAMAGE_DEALT = new GeneralStat("stat.damageDealt", 2020).register(); + public static Stat DAMAGE_TAKEN = new GeneralStat("stat.damageTaken", 2021).register(); + public static Stat DEATHS = new GeneralStat("stat.deaths", 2022).register(); + public static Stat MOBS_KILLED = new GeneralStat("stat.mobKills", 2023).register(); + public static Stat PLAYERS_KILLED = new GeneralStat("stat.playerKills", 2024).register(); + public static Stat FISH_CAUGHT = new GeneralStat("stat.fishCaught", 2025).register(); + + public static final Stat[] BLOCKS_MINED = new Stat[4096]; + public static final Stat[] ITEMS_CRAFTED = new Stat[32000]; + public static final Stat[] ITEMS_USED = new Stat[32000]; + public static final Stat[] ITEMS_BROKEN = new Stat[32000]; + + // For some reason vanilla stat IDs start at these numbers + private static final Integer MINE_BLOCK_PREFIX = 16777216; + private static final Integer USE_ITEM_PREFIX = 16908288; + private static final Integer BREAK_ITEM_PREFIX = 16973824; + private static final Integer CRAFT_ITEM_PREFIX = 16842752; + + public static void init() { + initBlocksMinedStats(); + initItemsUsedStats(); + initItemsBrokenStats(); + initItemsCraftedStats(); + } + + private static void initItemsCraftedStats() { + HashSet items = new HashSet<>(); + + for (var item : CraftingManager.getInstance().getRecipes()) { + var recipe = (CraftingRecipe) (Object) item; + + if (recipe.getResult() != null) { + items.add(recipe.getResult().getItem()); + } + } + + for (var itemStack : SmeltingManager.getInstance().getRecipes().values()) { + items.add(((ItemStack) (Object) itemStack).getItem()); + } + + for (Item item : items) { + if (item != null) { + int id = item.id; + ITEMS_CRAFTED[id] = new Stat("stat.craftItem." + id, CRAFT_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_CRAFTED); + } + + private static void initBlocksMinedStats() { + for (Block block : Block.BY_ID) { + if (block == null) { + continue; + } + + int id = block.id; + + if (id != 0 && block.hasStats()) { + BLOCKS_MINED[id] = new Stat("stat.mineBlock." + id, MINE_BLOCK_PREFIX + id).register(); + MINED.add(BLOCKS_MINED[id]); + } + } + + mergeBlockStats(BLOCKS_MINED); + } + + private static void initItemsUsedStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + ITEMS_USED[id] = new Stat("stat.useItem." + id, USE_ITEM_PREFIX + id).register(); + if (!(item instanceof BlockItem)) { + USED.add(ITEMS_USED[id]); + } + + } + + mergeBlockStats(ITEMS_USED); + } + + private static void initItemsBrokenStats() { + for (Item item : Item.BY_ID) { + if (item == null) { + continue; + } + + int id = item.id; + if (item.isDamageable()) { + ITEMS_BROKEN[id] = new Stat("stat.breakItem." + id, BREAK_ITEM_PREFIX + id).register(); + } + } + + mergeBlockStats(ITEMS_BROKEN); + } + + private static void mergeBlockStats(Stat[] stats) { + mergeBlockStats(stats, Block.WATER, Block.FLOWING_WATER); + mergeBlockStats(stats, Block.LAVA, Block.FLOWING_LAVA); + mergeBlockStats(stats, Block.LIT_PUMPKIN, Block.PUMPKIN); + mergeBlockStats(stats, Block.LIT_FURNACE, Block.FURNACE); + mergeBlockStats(stats, Block.LIT_REDSTONE_ORE, Block.REDSTONE_ORE); + mergeBlockStats(stats, Block.POWERED_REPEATER, Block.REPEATER); + // mergeBlockStats(stats, Block.POWERED_COMPARATOR, Block.COMPARATOR); + mergeBlockStats(stats, Block.REDSTONE_TORCH, Block.UNLIT_REDSTONE_TORCH); + // mergeBlockStats(stats, Block.LIT_REDSTONE_LAMP, Block.REDSTONE_LAMP); + mergeBlockStats(stats, Block.RED_MUSHROOM, Block.BROWN_MUSHROOM); + mergeBlockStats(stats, Block.DOUBLE_STONE_SLAB, Block.STONE_SLAB); + // mergeBlockStats(stats, Block.DOUBLE_WOODEN_SLAB, Block.WOODEN_SLAB); + mergeBlockStats(stats, Block.GRASS, Block.DIRT); + mergeBlockStats(stats, Block.FARMLAND, Block.DIRT); + } + + private static void mergeBlockStats(Stat[] stats, Block block1, Block block2) { + int id1 = block1.id; + int id2 = block2.id; + if (stats[id1] != null && stats[id2] == null) { + stats[id2] = stats[id1]; + } else { + ALL.remove(stats[id1]); + MINED.remove(stats[id1]); + GENERAL.remove(stats[id1]); + stats[id1] = stats[id2]; + } + } + + public static Stat createEntityKillStat(String key) { + return new Stat("stat.killEntity." + key, null).register(); + } + + public static Stat createKilledByEntityStat(String key) { + return new Stat("stat.entityKilledBy." + key, null).register(); + } + + public static Stat byKey(String key) { + return BY_KEY.get(key); + } + + public static Stat byVanillaId(Integer id) { + return BY_VANILLA_ID.get(id); + } + + public static Stat getEntityKillStat(Entity entity) { + return byKey("stat.killEntity." + Entities.getKey(entity)); + } + + public static Stat getKilledByEntityStat(Entity entity) { + return byKey("stat.entityKilledBy." + Entities.getKey(entity)); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java new file mode 100644 index 0000000..894364b --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/OverridableStats.java @@ -0,0 +1,9 @@ +package net.lostluma.server_stats.types; + +import java.util.Map; + +public interface OverridableStats { + public default void player_stats$override(Map override) { + throw new RuntimeException("No implementation for player_stats$override found."); + } +} \ No newline at end of file diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java new file mode 100644 index 0000000..283a2e6 --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/types/StatsPlayer.java @@ -0,0 +1,20 @@ +package net.lostluma.server_stats.types; + +import org.jetbrains.annotations.Nullable; + +import net.lostluma.server_stats.stats.ServerPlayerStats; +import net.lostluma.server_stats.stats.Stat; + +public interface StatsPlayer { + public default @Nullable ServerPlayerStats server_stats$getStats() { + throw new RuntimeException("No implementation for server_stats$getStats found."); + } + + public default void server_stats$incrementStat(Stat stat, int amount) { + throw new RuntimeException("No implementation for server_stats$incrementStat found."); + } + + public default void server_stats$saveStats() { + throw new RuntimeException("No implementation for server_stats$saveStats found."); + } +} diff --git a/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java new file mode 100644 index 0000000..d55c0ab --- /dev/null +++ b/versions/1.2.5-client/src/main/java/net/lostluma/server_stats/utils/Serialization.java @@ -0,0 +1,24 @@ +package net.lostluma.server_stats.utils; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.lostluma.server_stats.Constants; +import org.quiltmc.loader.api.QuiltLoader; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +public class Serialization { + private static final String BASE_PATH = "assets/" + Constants.MOD_ID + "/data"; + + public static Map getFromAssets(String name) throws IOException { + var container = QuiltLoader.getModContainer(Constants.MOD_ID).orElseThrow(); + var path = container.getPath(BASE_PATH + "/" + name + ".json"); + + Type type = new TypeToken>(){}.getType(); + return new Gson().fromJson(Files.readString(path, StandardCharsets.UTF_8), type); + } +} \ No newline at end of file diff --git a/versions/1.2.5-client/src/main/resources/assets/server_stats_mixins/icon.png b/versions/1.2.5-client/src/main/resources/assets/server_stats_mixins/icon.png new file mode 100644 index 0000000..8714235 Binary files /dev/null and b/versions/1.2.5-client/src/main/resources/assets/server_stats_mixins/icon.png differ diff --git a/versions/1.2.5-client/src/main/resources/quilt.mod.json b/versions/1.2.5-client/src/main/resources/quilt.mod.json new file mode 100644 index 0000000..04369ac --- /dev/null +++ b/versions/1.2.5-client/src/main/resources/quilt.mod.json @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "quilt_loader": { + "group": "net.lostluma", + "id": "server_stats_mixins", + "version": "${version}", + "metadata": { + "name": "Server Stats Mixins", + "description": "Implementation of server-side statistics for Minecraft 1.2.5 on the client.", + "contributors": { + "LostLuma": "Owner" + }, + "contact": { + "homepage": "https://go.lostluma.net/server-stats", + "issues": "https://go.lostluma.net/server-stats-issues", + "sources": "https://go.lostluma.net/server-stats-source" + }, + "license": { + "name": "LGPL License", + "id": "LGPL-3.0-or-later", + "url": "https://go.lostluma.net/server-stats-license" + }, + "icon": "assets/server_stats_mixins/icon.png" + }, + "intermediate_mappings": "net.fabricmc:intermediary", + "depends": [ + { + "id": "minecraft", + "versions": "=1.2.5" + } + ] + }, + "minecraft": { + "environment": "client" + }, + "mixin": "server_stats.mixins.json", + "access_widener" : "server_stats.accesswidener", + "quilt_loom": { + "injected_interfaces": { + "net/minecraft/unmapped/C_9590849": [ + "net/lostluma/server_stats/types/StatsPlayer" + ], + "net/minecraft/unmapped/C_2854223": [ + "net/lostluma/server_stats/types/OverridableStats" + ] + } + }, + "modmenu": { + "parent": "server_stats" + } +} diff --git a/versions/1.2.5-client/src/main/resources/server_stats.accesswidener b/versions/1.2.5-client/src/main/resources/server_stats.accesswidener new file mode 100644 index 0000000..894292a --- /dev/null +++ b/versions/1.2.5-client/src/main/resources/server_stats.accesswidener @@ -0,0 +1,3 @@ +accessWidener v2 named + +accessible field net/minecraft/client/Minecraft INSTANCE Lnet/minecraft/client/Minecraft; diff --git a/versions/1.2.5-client/src/main/resources/server_stats.mixins.json b/versions/1.2.5-client/src/main/resources/server_stats.mixins.json new file mode 100644 index 0000000..6c3ccfe --- /dev/null +++ b/versions/1.2.5-client/src/main/resources/server_stats.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "net.lostluma.server_stats.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [ + "common.EntitiesMixin", + "common.PlayerEntityMixin", + "common.WorldMixin" + ], + "client": [ + "client.LocalPlayerEntityMixin", + "client.MinecraftMixin", + "client.PacketHandlerMixin", + "client.PlayerStatsMixin" + ], + "server": [], + "injectors": { + "defaultRequire": 1 + } +}